feat: add Home page with user dashboard and habit tracking features
- Implemented Home component with user authentication and loading states. - Created Welcome component for unauthenticated users with a sign-in option. - Developed Dashboard component to display user's habits and progress. - Added functionality for habit management, including adding, updating, and deleting habits. - Integrated HabitChart and HabitHistory components for visual representation of habits. - Introduced sharing functionality for progress via Discord integration. feat: establish Discord sharing configuration and routes - Added DiscordSharingConfig type and readDiscordSharingConfig function for environment variable management. - Created sharing contracts for input validation and data structure. - Implemented sharing routes for previewing and sending progress images to Discord. - Added tests for sharing routes to ensure authentication and proper error handling.
This commit is contained in:
9
src/sharing/config.ts
Normal file
9
src/sharing/config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type DiscordSharingConfig = { token: string; channelId: string };
|
||||
|
||||
/** Read only from the server entrypoint; never include credentials in public config. */
|
||||
export function readDiscordSharingConfig(env = process.env): DiscordSharingConfig {
|
||||
return {
|
||||
token: (env.DISCORD_BOT_TOKEN ?? "").trim().replace(/^Bot\s+/i, ""),
|
||||
channelId: (env.DISCORD_SHARING_CHANNEL_ID ?? "").trim(),
|
||||
};
|
||||
}
|
||||
21
src/sharing/contracts.ts
Normal file
21
src/sharing/contracts.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
import { dateSchema } from "../habits/contracts";
|
||||
import { dayNumber } from "../habits/calendar";
|
||||
|
||||
export const shareInput = z.object({
|
||||
habitIds: z.array(z.string().uuid()).min(1).max(6).refine(ids => new Set(ids).size === ids.length),
|
||||
from: dateSchema,
|
||||
to: dateSchema,
|
||||
}).strict().refine(v => v.from <= v.to && dayNumber(v.to) - dayNumber(v.from) < 366, "Choose up to 366 days in chronological order");
|
||||
|
||||
export type ShareData = {
|
||||
from: string; to: string; timezone: string;
|
||||
botAvatarUrl?: string | null;
|
||||
habits: {
|
||||
name: string; color: string; completed: number; scheduled: number;
|
||||
legend: { empty: string; partial: string[]; complete: string };
|
||||
days: { date: string; color: string; due: boolean; complete: boolean; ratio: number | null }[];
|
||||
}[];
|
||||
};
|
||||
export type DiscordConnection = { connected: boolean; name?: string; channelUrl?: string; message?: string };
|
||||
export type Delivery = { status: "sent" | "uncertain"; messageUrl?: string };
|
||||
105
src/sharing/routes.test.ts
Normal file
105
src/sharing/routes.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { createApi } from "../api";
|
||||
import { fixture } from "../habits/test-fixture";
|
||||
import type { DiscordFetch } from "./routes";
|
||||
import type { DiscordSharingConfig } from "./config";
|
||||
import { cardStats } from "../lib/progress-card";
|
||||
|
||||
const fixtures: ReturnType<typeof fixture>[] = [];
|
||||
afterEach(() => { for (const f of fixtures.splice(0)) f.close(); });
|
||||
const botConfig = { token: "test-bot-token-do-not-expose", channelId: "223456789012345678" };
|
||||
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);
|
||||
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") };
|
||||
}
|
||||
// A small real PNG is not 1200px wide; use a header fixture for transport validation.
|
||||
function upload(id = crypto.randomUUID()) {
|
||||
const header = Buffer.alloc(40); Buffer.from("89504e470d0a1a0a", "hex").copy(header); header.write("IHDR", 12); header.writeUInt32BE(1200, 16); header.writeUInt32BE(940, 20);
|
||||
const data = new FormData(); data.set("image", new Blob([header], { type: "image/png" }), "progress.png"); data.set("deliveryId", id); return data;
|
||||
}
|
||||
|
||||
test("sharing requires authentication and same-origin mutations", async () => {
|
||||
const { call } = setup();
|
||||
expect((await call("/discord", "GET", undefined, "x")).status).toBe(401);
|
||||
expect((await call("/discord/send", "POST", upload(), "a", "https://evil.example")).status).toBe(403);
|
||||
expect((await call("/preview", "POST", {}, "a", "https://evil.example")).status).toBe(403);
|
||||
});
|
||||
test("missing or invalid bot configuration cannot make outbound requests", async () => {
|
||||
for (const config of [{ token: "", channelId: channel.id }, { token: "test", channelId: "https://evil.example" }, { token: "bad\nheader", channelId: channel.id }]) {
|
||||
let requests = 0;
|
||||
const { call } = setup(async () => { requests++; return Response.json(channel); }, config);
|
||||
expect((await (await call("/discord")).json()).connected).toBe(false);
|
||||
expect((await call("/discord/send", "POST", upload())).status).toBe(422);
|
||||
expect(requests).toBe(0);
|
||||
}
|
||||
});
|
||||
test("bot channel status exposes only the configured destination and caches channel metadata", async () => {
|
||||
let requests = 0;
|
||||
const { call } = setup(async (url, init) => {
|
||||
requests++; expect(url).toBe(`https://discord.com/api/v10/channels/${channel.id}`);
|
||||
expect(init?.redirect).toBe("error"); expect(new Headers(init?.headers).get("Authorization")).toBe(`Bot ${botConfig.token}`);
|
||||
return Response.json(channel);
|
||||
});
|
||||
const expected = { connected: true, name: "#progress", channelUrl: `https://discord.com/channels/${channel.guild_id}/${channel.id}` };
|
||||
const response = await call("/discord"); expect(response.status).toBe(200); expect(await response.json()).toEqual(expected);
|
||||
expect(await (await call("/discord", "GET", undefined, "b")).json()).toEqual(expected);
|
||||
expect(requests).toBe(1);
|
||||
expect((await call("/discord", "PUT", { url: "https://evil.example" })).status).toBe(404);
|
||||
expect((await call("/discord", "DELETE")).status).toBe(404);
|
||||
});
|
||||
test("bot errors do not expose the token or provider response", async () => {
|
||||
const { call } = setup(async () => { throw new Error(botConfig.token); });
|
||||
const response = await call("/discord"); expect(response.status).toBe(502); expect(await response.text()).not.toContain(botConfig.token);
|
||||
});
|
||||
test("card data respects ownership, recurrence, privacy boundaries, and date limits", async () => {
|
||||
const { f, call } = setup();
|
||||
f.setTime("2026-09-03T12:00:00Z");
|
||||
const habit = await f.json("/habits", "POST", { name: "Evening", method: "tasks", tasks: [{ name: "private task name" }], schedule: { type: "weekdays", days: [4] } }, 201);
|
||||
await f.json(`/habits/${habit.id}/days/2026-09-03/tasks/${habit.tasks[0].id}`, "PUT", { done: true });
|
||||
f.setTime("2026-09-04T12:00:00Z");
|
||||
const input = { habitIds: [habit.id], from: "2026-09-01", to: "2026-09-04" };
|
||||
const response = await call("/preview", "POST", input); expect(response.status).toBe(200);
|
||||
const data = await response.json(); expect(JSON.stringify(data)).not.toContain("private task name");
|
||||
expect(cardStats(data)).toEqual({ completed: 1, scheduled: 1, percent: 100 });
|
||||
expect(data.habits[0].legend).toEqual({ empty: "#ebedf0", partial: [], complete: "#196127" });
|
||||
expect(data.habits[0].days[3].due).toBe(false);
|
||||
expect((await call("/preview", "POST", input, "b")).status).toBe(404);
|
||||
for (const patch of [{ to: "2026-09-05" }, { from: "2024-01-01" }, { from: "2026-09-05" }, { habitIds: [] }, { habitIds: [habit.id, habit.id] }]) expect((await call("/preview", "POST", { ...input, ...patch })).status).toBe(422);
|
||||
});
|
||||
test("delivery sends exactly the PNG with mentions disabled and deduplicates retries", async () => {
|
||||
let posts = 0;
|
||||
const { call, connect } = setup(async (target, init) => {
|
||||
if (init?.method !== "POST") return Response.json(channel);
|
||||
posts++; expect(target).toBe(`https://discord.com/api/v10/channels/${channel.id}/messages`);
|
||||
expect(new Headers(init.headers).get("Authorization")).toBe(`Bot ${botConfig.token}`);
|
||||
const form = init.body as FormData; const payload = JSON.parse(form.get("payload_json") as string);
|
||||
expect(payload.allowed_mentions).toEqual({ parse: [] }); expect(payload.username).toBeUndefined(); expect(payload.enforce_nonce).toBe(true); expect(payload.nonce).toHaveLength(24);
|
||||
expect(payload.content).toBeUndefined(); expect((form.get("files[0]") as File).type).toBe("image/png");
|
||||
return Response.json({ id: "423456789012345678" });
|
||||
});
|
||||
await connect(); const id = crypto.randomUUID();
|
||||
const first = await call("/discord/send", "POST", upload(id)); expect(first.status).toBe(200);
|
||||
expect(await first.json()).toEqual({ status: "sent", messageUrl: "https://discord.com/channels/323456789012345678/223456789012345678/423456789012345678" });
|
||||
expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("sent"); expect(posts).toBe(1);
|
||||
});
|
||||
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) => {
|
||||
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 (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);
|
||||
});
|
||||
test("invalid files and absent bot configuration cannot send", async () => {
|
||||
let posts = 0; const { call, connect } = setup(async (_, init) => { if (init?.method === "POST") posts++; return Response.json(channel); });
|
||||
const disabled = setup(undefined, { token: "", channelId: "" });
|
||||
expect((await disabled.call("/discord/send", "POST", upload())).status).toBe(422);
|
||||
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);
|
||||
});
|
||||
134
src/sharing/routes.ts
Normal file
134
src/sharing/routes.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Hono } from "hono";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import type { AppDatabase, AuthEnv, createAuth } from "../auth";
|
||||
import { discordDeliveries } 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." });
|
||||
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 (!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 });
|
||||
}
|
||||
db.insert(discordDeliveries).values({ id: id.data, userId, imageHash, status: "pending", createdAt: now() }).run();
|
||||
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");
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user