feat: add authenticated share publishing APIs

This commit is contained in:
2026-08-16 00:50:55 +07:00 Unverified
parent b8e6389ac3
commit 5bf5d2a2c0
8 changed files with 407 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
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 { getDiscordMemberProfile } from "@/lib/discord/discord";
import { createShare } from "@/lib/share/create";
import { ShareHttpError, shareErrorResponse } from "@/lib/share/http-error";
import { enforceShareRateLimit } from "@/lib/share/rate-limit";
import {
validateOptionalImage,
validateShareDescription,
validateTextFile,
} from "@/lib/share/validation";
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);
}
export async function POST(request: Request) {
try {
if (!hasValidBearerToken(request)) {
throw new ShareHttpError("Unauthorized", 401);
}
await enforceShareRateLimit({
key: "bot:global",
limit: 60,
windowSeconds: 60 * 60,
});
const formData = await request.formData();
const file = formData.get("file");
const senderDiscordId = formData.get("senderDiscordId");
if (!(file instanceof File)) {
throw new ShareHttpError("A .txt file is required", 400);
}
if (
typeof senderDiscordId !== "string" ||
!/^\d{15,22}$/.test(senderDiscordId)
) {
throw new ShareHttpError("A valid senderDiscordId is required", 400);
}
const [content, image] = await Promise.all([
validateTextFile(file),
validateOptionalImage(formData.get("image")),
]);
const description = validateShareDescription(formData.get("description"));
const profile = await getDiscordMemberProfile(senderDiscordId);
if (!profile) {
throw new ShareHttpError(
"Discord sender was not found in the configured guild",
422
);
}
await enforceShareRateLimit({
key: `sender:${senderDiscordId}`,
limit: 10,
windowSeconds: 60 * 60,
});
const displayName = profile.globalName?.trim() || profile.username;
const user = await ensureDiscordUser({
discordId: senderDiscordId,
displayName,
avatarUrl: profile.avatarUrl,
});
const share = await createShare({
content,
description,
image,
source: "bot",
author: {
userId: user.id,
discordId: senderDiscordId,
displayName,
avatarUrl: profile.avatarUrl,
},
});
const url = `${getCanonicalUrl()}/share/${share.id}`;
return Response.json(
{
success: true,
share: {
id: share.id,
url,
author: {
displayName,
avatarUrl: profile.avatarUrl,
},
imageUrl: cdnUrl(share.imageCdnId) || null,
createdAt: share.createdAt.toISOString(),
},
},
{ status: 201 }
);
} catch (error) {
return shareErrorResponse(error);
}
}
+65
View File
@@ -0,0 +1,65 @@
import { getServerSession } from "next-auth";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { users } from "@/db/schema";
import { authOptions } from "@/lib/auth/auth-options";
import { getSessionDiscordId, type SessionWithDiscord } from "@/lib/auth/auth";
import { createShare } from "@/lib/share/create";
import { ShareHttpError, shareErrorResponse } from "@/lib/share/http-error";
import { enforceShareRateLimit } from "@/lib/share/rate-limit";
import {
validateOptionalImage,
validateShareDescription,
validateShareText,
} from "@/lib/share/validation";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
try {
const session = await getServerSession(authOptions);
const discordId = getSessionDiscordId(session);
const userId = (session as SessionWithDiscord | null)?.user?.id;
if (!discordId || !userId) {
throw new ShareHttpError("Unauthorized", 401);
}
const formData = await request.formData();
const content = validateShareText(formData.get("text"));
const description = validateShareDescription(formData.get("description"));
const image = await validateOptionalImage(formData.get("image"));
await enforceShareRateLimit({
key: `sender:${discordId}`,
limit: 10,
windowSeconds: 60 * 60,
});
const [user] = await db
.select({ id: users.id, name: users.name, image: users.image })
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!user) throw new ShareHttpError("Unauthorized", 401);
const share = await createShare({
content,
description,
image,
source: "web",
author: {
userId: user.id,
discordId,
displayName: user.name || session?.user?.name || "Discord user",
avatarUrl: user.image || session?.user?.image || null,
},
});
return Response.json(
{ success: true, share: { id: share.id, url: `/share/${share.id}` } },
{ status: 201 }
);
} catch (error) {
return shareErrorResponse(error);
}
}