diff --git a/README.md b/README.md index 66a9a00..bb1cb3e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/api/share/route.ts b/app/api/share/route.ts new file mode 100644 index 0000000..39c1435 --- /dev/null +++ b/app/api/share/route.ts @@ -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); + } +} diff --git a/app/api/share/web/route.ts b/app/api/share/web/route.ts new file mode 100644 index 0000000..442b6a2 --- /dev/null +++ b/app/api/share/web/route.ts @@ -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); + } +} diff --git a/lib/config/server.ts b/lib/config/server.ts index 3f3cb4f..847192d 100644 --- a/lib/config/server.ts +++ b/lib/config/server.ts @@ -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", diff --git a/lib/share/create.ts b/lib/share/create.ts new file mode 100644 index 0000000..de2d179 --- /dev/null +++ b/lib/share/create.ts @@ -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; + }); +} diff --git a/lib/share/http-error.ts b/lib/share/http-error.ts new file mode 100644 index 0000000..a023a67 --- /dev/null +++ b/lib/share/http-error.ts @@ -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 }); +} diff --git a/lib/share/rate-limit.ts b/lib/share/rate-limit.ts new file mode 100644 index 0000000..643f3de --- /dev/null +++ b/lib/share/rate-limit.ts @@ -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); + } +} diff --git a/lib/share/validation.ts b/lib/share/validation.ts new file mode 100644 index 0000000..36b1bb6 --- /dev/null +++ b/lib/share/validation.ts @@ -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; +}