diff --git a/app/api/share/route.ts b/app/api/share/route.ts index 79e1561..166a382 100644 --- a/app/api/share/route.ts +++ b/app/api/share/route.ts @@ -1,11 +1,13 @@ -import { timingSafeEqual } from "node:crypto"; import { cdnUrl } from "@/lib/cdn-images"; import { ensureDiscordUser } from "@/lib/auth/discord-user"; import { getCanonicalUrl, getShareBotSecret } from "@/lib/config/server"; +import { getDiscordSenderProfile } from "@/lib/discord/discord"; import { - DiscordProfileLookupError, - getDiscordSenderProfile, -} from "@/lib/discord/discord"; + buildBotShareResponse, + hasValidShareBotBearer, + rejectSuppliedBotProfile, + resolveBotSenderProfile, +} from "@/lib/share/bot"; import { createShare } from "@/lib/share/create"; import { ShareHttpError, shareErrorResponse } from "@/lib/share/http-error"; import { enforceShareRateLimit } from "@/lib/share/rate-limit"; @@ -19,19 +21,13 @@ export const dynamic = "force-dynamic"; function hasValidBearerToken(request: Request) { const authorization = request.headers.get("authorization"); - if (!authorization?.startsWith("Bearer ")) return false; - let expected: string; try { expected = getShareBotSecret(); } catch { throw new ShareHttpError("Share bot API is not configured", 503); } - const supplied = authorization.slice("Bearer ".length); - const suppliedBytes = Buffer.from(supplied); - const expectedBytes = Buffer.from(expected); - return suppliedBytes.length === expectedBytes.length && - timingSafeEqual(suppliedBytes, expectedBytes); + return hasValidShareBotBearer(authorization, expected); } export async function POST(request: Request) { @@ -53,19 +49,7 @@ export async function POST(request: Request) { } catch { throw new ShareHttpError("Malformed multipart form data", 400); } - for (const field of [ - "displayName", - "avatarUrl", - "authorName", - "authorAvatarUrl", - ]) { - if (formData.has(field)) { - throw new ShareHttpError( - `Profile field ${field} must not be supplied`, - 400, - ); - } - } + rejectSuppliedBotProfile(formData); const file = formData.get("file"); const senderDiscordId = formData.get("senderDiscordId"); if (!(file instanceof File)) { @@ -83,21 +67,10 @@ export async function POST(request: Request) { validateOptionalImage(formData.get("image")), ]); const description = validateShareDescription(formData.get("description")); - let profile; - try { - profile = await getDiscordSenderProfile(senderDiscordId); - } catch (error) { - if (error instanceof DiscordProfileLookupError) { - throw new ShareHttpError("Discord profile lookup is unavailable", 503); - } - throw error; - } - if (!profile) { - throw new ShareHttpError( - "Discord sender was not found in the configured guild", - 422 - ); - } + const profile = await resolveBotSenderProfile( + senderDiscordId, + getDiscordSenderProfile, + ); await enforceShareRateLimit({ key: `sender:${senderDiscordId}`, @@ -123,24 +96,14 @@ export async function POST(request: Request) { avatarUrl: profile.avatarUrl, }, }); - const url = `${baseUrl}/share/${share.id}`; - return Response.json( - { - success: true, - share: { - id: share.id, - url, - author: { - displayName, - avatarUrl: profile.avatarUrl, - }, - imageUrl: share.imageCdnId - ? `${baseUrl}${cdnUrl(share.imageCdnId)}` - : null, - createdAt: share.createdAt.toISOString(), - }, - }, + buildBotShareResponse({ + baseUrl, + share, + displayName, + avatarUrl: profile.avatarUrl, + imagePath: share.imageCdnId ? cdnUrl(share.imageCdnId) : null, + }), { status: 201 } ); } catch (error) { diff --git a/lib/share/bot.test.ts b/lib/share/bot.test.ts new file mode 100644 index 0000000..ac2278c --- /dev/null +++ b/lib/share/bot.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { + buildBotShareResponse, + hasValidShareBotBearer, + rejectSuppliedBotProfile, + resolveBotSenderProfile, +} from "@/lib/share/bot"; + +describe("share bot contract", () => { + test("requires an exact bearer secret", () => { + expect(hasValidShareBotBearer(null, "secret")).toBeFalse(); + expect(hasValidShareBotBearer("Basic secret", "secret")).toBeFalse(); + expect(hasValidShareBotBearer("Bearer wrong", "secret")).toBeFalse(); + expect(hasValidShareBotBearer("Bearer secret", "secret")).toBeTrue(); + }); + + test("rejects request-provided identity snapshots", () => { + const formData = new FormData(); + formData.set("displayName", "Untrusted name"); + expect(() => rejectSuppliedBotProfile(formData)).toThrow("must not be supplied"); + }); + + test("distinguishes an invalid guild sender from lookup downtime", async () => { + await expect( + resolveBotSenderProfile("1", async () => null), + ).rejects.toMatchObject({ status: 422 }); + await expect( + resolveBotSenderProfile("1", async () => { + throw new Error("Discord unavailable"); + }), + ).rejects.toMatchObject({ status: 503 }); + }); + + test("returns the documented successful response shape", () => { + expect( + buildBotShareResponse({ + baseUrl: "https://example.com", + share: { + id: "a1b2c3d4", + createdAt: new Date("2026-08-16T00:00:00.000Z"), + }, + displayName: "Example User", + avatarUrl: "https://cdn.discordapp.com/avatar.png", + imagePath: "/api/cdn/image-1", + }), + ).toEqual({ + success: true, + share: { + id: "a1b2c3d4", + url: "https://example.com/share/a1b2c3d4", + author: { + displayName: "Example User", + avatarUrl: "https://cdn.discordapp.com/avatar.png", + }, + imageUrl: "https://example.com/api/cdn/image-1", + createdAt: "2026-08-16T00:00:00.000Z", + }, + }); + }); +}); diff --git a/lib/share/bot.ts b/lib/share/bot.ts new file mode 100644 index 0000000..fb44aa4 --- /dev/null +++ b/lib/share/bot.ts @@ -0,0 +1,82 @@ +import { timingSafeEqual } from "node:crypto"; +import { ShareHttpError } from "@/lib/share/http-error"; + +export type BotSenderProfile = { + id: string; + username: string; + globalName: string | null; + avatarUrl: string | null; +}; + +const FORBIDDEN_PROFILE_FIELDS = [ + "displayName", + "avatarUrl", + "authorName", + "authorAvatarUrl", +] as const; + +export function hasValidShareBotBearer( + authorization: string | null, + expected: string, +) { + if (!authorization?.startsWith("Bearer ")) return false; + const suppliedBytes = Buffer.from(authorization.slice("Bearer ".length)); + const expectedBytes = Buffer.from(expected); + return suppliedBytes.length === expectedBytes.length && + timingSafeEqual(suppliedBytes, expectedBytes); +} + +export function rejectSuppliedBotProfile(formData: FormData) { + for (const field of FORBIDDEN_PROFILE_FIELDS) { + if (formData.has(field)) { + throw new ShareHttpError( + `Profile field ${field} must not be supplied`, + 400, + ); + } + } +} + +export async function resolveBotSenderProfile( + discordId: string, + lookup: (discordId: string) => Promise, +) { + let profile: BotSenderProfile | null; + try { + profile = await lookup(discordId); + } catch { + throw new ShareHttpError("Discord profile lookup is unavailable", 503); + } + if (!profile) { + throw new ShareHttpError( + "Discord sender was not found in the configured guild", + 422, + ); + } + return profile; +} + +export function buildBotShareResponse({ + baseUrl, + share, + displayName, + avatarUrl, + imagePath, +}: { + baseUrl: string; + share: { id: string; createdAt: Date }; + displayName: string; + avatarUrl: string | null; + imagePath: string | null; +}) { + return { + success: true as const, + share: { + id: share.id, + url: `${baseUrl}/share/${share.id}`, + author: { displayName, avatarUrl }, + imageUrl: imagePath ? `${baseUrl}${imagePath}` : null, + createdAt: share.createdAt.toISOString(), + }, + }; +}