feat: add authenticated share publishing APIs
This commit is contained in:
@@ -38,6 +38,7 @@ Create `.env.local` for local development. The application uses these groups of
|
||||
- `NEXTAUTH_URL`, `NEXTAUTH_SECRET` — authentication configuration
|
||||
- `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` — Discord OAuth
|
||||
- `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
|
||||
- `BASE_URL` — canonical public URL used in links and OAuth callbacks
|
||||
- platform credentials used by follower-count integrations
|
||||
@@ -57,6 +58,8 @@ bun run secrets:scan
|
||||
- `/leaderboard/vc` — voice activity leaderboard
|
||||
- `/sse/[topic]` — authenticated realtime updates
|
||||
- `/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
|
||||
|
||||
Forms use server actions for authorization, persistence, validation, Discord webhooks, and cache invalidation. Public form drafts are stored locally in the browser.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import "server-only";
|
||||
import {
|
||||
optionalEnvironmentValue,
|
||||
readFeatureEnvironment,
|
||||
requiredEnvironmentValue,
|
||||
requiredEnvironmentUrl,
|
||||
} from "@/lib/config/environment";
|
||||
|
||||
@@ -32,6 +33,10 @@ export function getDiscordServerConfig() {
|
||||
};
|
||||
}
|
||||
|
||||
export function getShareBotSecret() {
|
||||
return requiredEnvironmentValue("SHARE_BOT_SECRET");
|
||||
}
|
||||
|
||||
export function getYoutubeFollowerConfig() {
|
||||
const config = readFeatureEnvironment("YouTube follower counts", [
|
||||
"YOUTUBE_API_KEY",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user