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
+3
View File
@@ -38,6 +38,7 @@ Create `.env.local` for local development. The application uses these groups of
- `NEXTAUTH_URL`, `NEXTAUTH_SECRET` — authentication configuration - `NEXTAUTH_URL`, `NEXTAUTH_SECRET` — authentication configuration
- `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` — Discord OAuth - `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` — Discord OAuth
- `DISCORD_BOT_TOKEN`, `DISCORD_GUILD_ID` — Discord role and profile lookups - `DISCORD_BOT_TOKEN`, `DISCORD_GUILD_ID` — Discord role and profile lookups
- `SHARE_BOT_SECRET` — bearer secret for private Discord bot share uploads
- `ADMIN_DISCORD_IDS` — comma-separated Discord IDs allowed into admin tools - `ADMIN_DISCORD_IDS` — comma-separated Discord IDs allowed into admin tools
- `BASE_URL` — canonical public URL used in links and OAuth callbacks - `BASE_URL` — canonical public URL used in links and OAuth callbacks
- platform credentials used by follower-count integrations - platform credentials used by follower-count integrations
@@ -57,6 +58,8 @@ bun run secrets:scan
- `/leaderboard/vc` — voice activity leaderboard - `/leaderboard/vc` — voice activity leaderboard
- `/sse/[topic]` — authenticated realtime updates - `/sse/[topic]` — authenticated realtime updates
- `/api/upload` — authenticated image uploads stored in PostgreSQL - `/api/upload` — authenticated image uploads stored in PostgreSQL
- `/share` — Discord-authenticated text publishing with public share pages
- `/api/share` — private bearer-authenticated Discord bot publishing endpoint
- `/admin/upload` — admin video publishing workspace for YouTube and TikTok - `/admin/upload` — admin video publishing workspace for YouTube and TikTok
Forms use server actions for authorization, persistence, validation, Discord webhooks, and cache invalidation. Public form drafts are stored locally in the browser. Forms use server actions for authorization, persistence, validation, Discord webhooks, and cache invalidation. Public form drafts are stored locally in the browser.
+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);
}
}
+5
View File
@@ -3,6 +3,7 @@ import "server-only";
import { import {
optionalEnvironmentValue, optionalEnvironmentValue,
readFeatureEnvironment, readFeatureEnvironment,
requiredEnvironmentValue,
requiredEnvironmentUrl, requiredEnvironmentUrl,
} from "@/lib/config/environment"; } from "@/lib/config/environment";
@@ -32,6 +33,10 @@ export function getDiscordServerConfig() {
}; };
} }
export function getShareBotSecret() {
return requiredEnvironmentValue("SHARE_BOT_SECRET");
}
export function getYoutubeFollowerConfig() { export function getYoutubeFollowerConfig() {
const config = readFeatureEnvironment("YouTube follower counts", [ const config = readFeatureEnvironment("YouTube follower counts", [
"YOUTUBE_API_KEY", "YOUTUBE_API_KEY",
+64
View File
@@ -0,0 +1,64 @@
import "server-only";
import { db } from "@/db";
import { cdn, shareTexts } from "@/db/schema";
import type { ShareSource } from "@/db/schema/share";
import type { ValidatedImage } from "@/lib/share/validation";
export type ShareAuthor = {
userId: string;
discordId: string;
displayName: string;
avatarUrl: string | null;
};
export async function createShare({
content,
description,
image,
source,
author,
}: {
content: string;
description: string | null;
image: ValidatedImage | null;
source: ShareSource;
author: ShareAuthor;
}) {
return db.transaction(async (tx) => {
let imageCdnId: string | null = null;
if (image) {
const [record] = await tx
.insert(cdn)
.values({
data: image.bytes,
name: image.name,
type: image.type,
size: image.size,
ownerDiscordId: author.discordId,
})
.returning({ id: cdn.id });
imageCdnId = record.id;
}
const [share] = await tx
.insert(shareTexts)
.values({
content,
description,
imageCdnId,
source,
authorId: author.userId,
authorDiscordId: author.discordId,
authorName: author.displayName,
authorAvatarUrl: author.avatarUrl,
})
.returning({
id: shareTexts.id,
imageCdnId: shareTexts.imageCdnId,
createdAt: shareTexts.createdAt,
});
return share;
});
}
+21
View File
@@ -0,0 +1,21 @@
export class ShareHttpError extends Error {
constructor(
message: string,
readonly status: number,
readonly retryAfter?: number
) {
super(message);
this.name = "ShareHttpError";
}
}
export function shareErrorResponse(error: unknown) {
const known = error instanceof ShareHttpError;
const status = known ? error.status : 500;
const message = known ? error.message : "Internal server error";
const headers = new Headers();
if (known && error.retryAfter) {
headers.set("Retry-After", String(error.retryAfter));
}
return Response.json({ success: false, error: message }, { status, headers });
}
+44
View File
@@ -0,0 +1,44 @@
import "server-only";
import { getRedisClient } from "@/lib/redis";
import { ShareHttpError } from "@/lib/share/http-error";
const FIXED_WINDOW_SCRIPT = `
local count = redis.call("INCR", KEYS[1])
if count == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return count
`;
export async function enforceShareRateLimit({
key,
limit,
windowSeconds,
}: {
key: string;
limit: number;
windowSeconds: number;
}) {
const nowSeconds = Math.floor(Date.now() / 1000);
const window = Math.floor(nowSeconds / windowSeconds);
const retryAfter = windowSeconds - (nowSeconds % windowSeconds);
try {
const redis = await getRedisClient();
const count = Number(
await redis.send("EVAL", [
FIXED_WINDOW_SCRIPT,
"1",
`erika:share:${key}:${window}`,
String(windowSeconds + 1),
])
);
if (count > limit) {
throw new ShareHttpError("Rate limit exceeded", 429, retryAfter);
}
} catch (error) {
if (error instanceof ShareHttpError) throw error;
throw new ShareHttpError("Rate limiting is temporarily unavailable", 503);
}
}
+88
View File
@@ -0,0 +1,88 @@
import { validateImageUploadFile } from "@/lib/cdn-images";
import { ShareHttpError } from "@/lib/share/http-error";
export const MAX_SHARE_TEXT_BYTES = 5 * 1024 * 1024;
export const MAX_SHARE_DESCRIPTION_LENGTH = 5_000;
export const MAX_SHARE_COMMENT_LENGTH = 2_000;
export type ValidatedImage = {
bytes: Buffer;
name: string | null;
type: string;
size: number;
};
export function validateShareText(content: unknown) {
if (typeof content !== "string") {
throw new ShareHttpError("Text is required", 400);
}
if (!content.trim()) {
throw new ShareHttpError("Text cannot be blank", 422);
}
if (Buffer.byteLength(content, "utf8") > MAX_SHARE_TEXT_BYTES) {
throw new ShareHttpError("Text must be 5 MiB or smaller", 413);
}
return content;
}
export function validateShareDescription(value: unknown) {
if (value === null || value === undefined || value === "") return null;
if (typeof value !== "string") {
throw new ShareHttpError("Description must be text", 400);
}
if (value.length > MAX_SHARE_DESCRIPTION_LENGTH) {
throw new ShareHttpError("Description must be 5,000 characters or fewer", 413);
}
return value;
}
export function validateShareComment(value: unknown) {
if (typeof value !== "string" || !value.trim()) {
throw new ShareHttpError("Comment cannot be blank", 422);
}
if (value.length > MAX_SHARE_COMMENT_LENGTH) {
throw new ShareHttpError("Comment must be 2,000 characters or fewer", 413);
}
return value;
}
export async function validateTextFile(file: File) {
if (!file.name.toLowerCase().endsWith(".txt")) {
throw new ShareHttpError("File must use the .txt extension", 415);
}
if (file.size > MAX_SHARE_TEXT_BYTES) {
throw new ShareHttpError("Text file must be 5 MiB or smaller", 413);
}
if (file.size === 0) {
throw new ShareHttpError("Text file cannot be empty", 422);
}
try {
const bytes = await file.arrayBuffer();
const content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
return validateShareText(content);
} catch (error) {
if (error instanceof ShareHttpError) throw error;
throw new ShareHttpError("Text file must contain valid UTF-8", 422);
}
}
export async function validateOptionalImage(value: FormDataEntryValue | null) {
if (!(value instanceof File) || value.size === 0) return null;
const bytes = Buffer.from(await value.arrayBuffer());
try {
validateImageUploadFile(value, bytes);
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid image";
const status = message.includes("50MB") ? 413 : 415;
throw new ShareHttpError(message, status);
}
return {
bytes,
name: value.name || null,
type: value.type,
size: value.size,
} satisfies ValidatedImage;
}