feat: add private Discord progress and explicit shared delivery controls

This commit is contained in:
syntaxbullet
2026-09-08 08:23:55 +02:00
parent 5dbec04b04
commit a1ae65634b
11 changed files with 318 additions and 39 deletions

View File

@@ -8,6 +8,7 @@ import { liveDiscordCards, users } from "../db/schema";
import { ApiError } from "../habits/service";
import { endOfDay, localDate } from "../habits/calendar";
import { liveCardData, renderLiveCard } from "./live-card-image";
import type { PublicUser } from "../shared/user";
import type { DiscordSharingConfig } from "./config";
import type { DiscordFetch } from "./routes";
@@ -155,12 +156,11 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
c.header('Content-Type', 'image/png'); c.header('Content-Disposition', 'inline; filename="minabot-live-progress.png"');
return c.body(new Uint8Array(image));
});
app.post('/', async c => {
if (config.writesEnabled === false) return c.json({ error: 'Discord channel writes are disabled in development. Preview and download are still available.' }, 403);
async function start(user: PublicUser, theme: 'light' | 'dark') {
if (config.writesEnabled === false) throw new ApiError(403, 'Discord channel writes are disabled in development. Preview and download are still available.');
if (!configured) throw new ApiError(422, 'Discord sharing has not been configured on this server.');
const user = c.get('user');
if (!allowed(user.discordId)) return c.json({ error: 'Discord sharing is limited to approved community members.' }, 403);
const input = options.safeParse(await c.req.json().catch(() => null));
if (!allowed(user.discordId)) throw new ApiError(403, 'Discord sharing is limited to approved community members.');
const input = options.safeParse({ theme });
if (!input.success) throw new ApiError(422, 'Choose a card theme.');
const existing = row(user.id);
if (existing?.status === 'uncertain')
@@ -171,23 +171,28 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
.onConflictDoUpdate({ target: liveDiscordCards.userId, set: { theme: input.data.theme, status: existing?.messageId ? 'active' : 'sending', dirty: true, error: null } }).run();
}
await flush(user.id);
return c.json(state(user.id));
});
app.delete('/', async c => {
const id = c.get('user').id;
return state(user.id);
}
async function pause(id: string) {
if (row(id)?.status !== 'sending' && row(id)?.status !== 'uncertain') update(id, { status: 'paused' });
clearTimeout(timers.get(id)); timers.delete(id);
// Return only after any already-sent edit has finished.
await running.get(id);
if (row(id)?.status !== 'uncertain') update(id, { status: 'paused' });
clearTimeout(timers.get(id)); timers.delete(id);
return c.json(state(id));
return state(id);
}
app.post('/', async c => {
const input = options.safeParse(await c.req.json().catch(() => null));
if (!input.success) throw new ApiError(422, 'Choose a card theme.');
return c.json(await start(c.get('user'), input.data.theme));
});
app.delete('/', async c => c.json(await pause(c.get('user').id)));
// Recover only known message edits. Never risk duplicating an interrupted first post.
for (const current of db.select().from(liveDiscordCards).all()) {
if (config.writesEnabled === false) break;
if (current.status === 'sending') update(current.userId, { status: 'uncertain', error: 'Server restarted during initial delivery. Check Discord before posting again.' });
else if (current.status === 'active') schedule(current.userId, Math.max(2000, (current.retryAt ?? 0) - now()));
}
return { routes: app, changed, flush, stop };
return { routes: app, changed, flush, stop, state, start, pause };
}

View File

@@ -8,6 +8,7 @@ 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 { PublicUser } from "../shared/user";
import type { DiscordSharingConfig } from "./config";
export type DiscordFetch = (url: string, init?: RequestInit) => Promise<Response>;
@@ -98,14 +99,15 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
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);
function assertSend(user: PublicUser) {
if (config.writesEnabled === false) throw new ApiError(403, "Discord channel writes are disabled in development. Preview and download are still available.");
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(user.discordId)) throw new ApiError(403, "Discord sharing is limited to approved community members.");
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");
}
async function send(user: PublicUser, deliveryId: string, image: File) {
assertSend(user);
const userId = user.id;
const id = z.string().uuid().safeParse(deliveryId);
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.");
@@ -114,7 +116,7 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
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 });
return ({ status: previous.status === "sent" ? "sent" : "uncertain", messageUrl: previous.messageUrl ?? undefined });
}
const timestamp = now();
// Reserve both quotas and the delivery before any asynchronous outbound work.
@@ -128,7 +130,7 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
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 },
{ key: `channel:${config.channelId}:user:${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)));
@@ -142,20 +144,19 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
}, { 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 });
return ({ 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);
throw Object.assign(new ApiError(429, `Sharing limit reached. Try again in ${retryAfter} seconds.`), { retryAfter });
}
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" }); }
catch { return ({ status: "uncertain" }); }
if (!response.ok) {
if (response.status >= 500) return c.json({ status: "uncertain" });
if (response.status >= 500) return ({ 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).");
@@ -163,7 +164,20 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
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 ({ status: "sent", messageUrl: messageUrl ?? undefined });
}
app.post("/discord/send", async c => {
assertSend(c.get("user"));
const form = await c.req.formData().catch(() => { throw new ApiError(400, "Expected a progress image."); });
try { return c.json(await send(c.get("user"), String(form.get("deliveryId")), form.get("image") as File)); }
catch (error) {
if (error instanceof ApiError && 'retryAfter' in error) c.header("Retry-After", String(error.retryAfter));
throw error;
}
});
return app;
async function destination(user: PublicUser) {
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(user.discordId)) throw new ApiError(403, "Discord sharing is limited to approved community members.");
return channel();
}
return Object.assign(app, { send, destination });
}