Harden form auth and submission handling

This commit is contained in:
2026-07-09 23:53:40 +07:00 Unverified
parent 2c82d55839
commit 8c0cc4492b
43 changed files with 747 additions and 534 deletions
+2 -2
View File
@@ -31,9 +31,9 @@ This successfully provides a fully responsive, easily configurable, and containe
## 5. Form Module Structure
- **Public Form Viewer:** Located at `app/form/page.tsx` and `app/form/client.tsx`. This is where users see and submit the form. It renders questions and handles user input with validation.
- **Admin Form Builder:** Located at `app/form/admin/page.tsx` and `app/form/admin/client.tsx`. This interface allows administrators to create and edit questions, change form settings, and specify input types (Text, Textarea, Radio, Checkboxes).
- **Admin Form Builder:** Located under `app/admin/form/`. This interface allows administrators to create and edit questions, change form settings, and specify input types (Text, Textarea, Radio, Checkboxes).
- **Database Schema:** Defined in `db/schema/form.ts`, which contains definitions for `forms`, `questions`, `submissions`, and `answers`.
- **Server Actions:** Backend logic for managing the form (such as creating/updating questions and handling submissions) are located in `app/actions/form.ts`, `app/actions/questions.ts`, and `app/actions/submissions.ts`.
- **Server Actions:** Backend logic for managing forms and submissions is located in `app/admin/form/actions.ts`, `app/actions/questions.ts`, and `app/actions/submissions.ts`. Shared authorization lives in `lib/auth.ts`.
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
+3 -143
View File
@@ -1,149 +1,9 @@
"use server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { requireAdmin } from "@/lib/auth";
import { getGuildRolesInternal, type DiscordRole } from "@/lib/discord";
export interface DiscordRole {
id: string;
name: string;
color: number;
position: number;
}
// --- In-memory caches with TTL ---
interface CacheEntry<T> { data: T; expiresAt: number }
const rolesCache: { entry: CacheEntry<DiscordRole[]> | null } = { entry: null };
const profileCache = new Map<string, CacheEntry<any>>();
const CACHE_TTL = 60_000; // 60 seconds
function getCached<T>(entry: CacheEntry<T> | null | undefined): T | null {
if (entry && Date.now() < entry.expiresAt) return entry.data;
return null;
}
/** Internal: fetch guild roles without auth check (for use by other server functions). */
async function getGuildRolesInternal(): Promise<DiscordRole[]> {
const cached = getCached(rolesCache.entry);
if (cached) return cached;
const token = process.env.DISCORD_BOT_TOKEN;
const guildId = process.env.DISCORD_GUILD_ID;
if (!token || !guildId) return [];
try {
const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/roles`, {
headers: { Authorization: `Bot ${token}` },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return [];
const roles: any[] = await res.json();
const result = roles.map(r => ({
id: r.id,
name: r.name,
color: r.color,
position: r.position,
})).sort((a, b) => b.position - a.position);
rolesCache.entry = { data: result, expiresAt: Date.now() + CACHE_TTL };
return result;
} catch {
return [];
}
}
/** Public: fetch guild roles with admin auth check. */
export async function getGuildRoles(): Promise<DiscordRole[]> {
const session = await getServerSession(authOptions);
const discordId = (session?.user as any)?.discordId;
const admins = (process.env.ADMIN_DISCORD_IDS || "").split(",");
if (!discordId || !admins.includes(discordId)) {
throw new Error("Unauthorized");
}
await requireAdmin();
return getGuildRolesInternal();
}
export async function getGuildMemberRoles(discordId: string): Promise<string[]> {
const token = process.env.DISCORD_BOT_TOKEN;
const guildId = process.env.DISCORD_GUILD_ID;
if (!token || !guildId || !discordId) return [];
try {
const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${discordId}`, {
headers: { Authorization: `Bot ${token}` },
cache: "no-store", // Always fresh for access control
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return [];
const member = await res.json();
return member.roles || [];
} catch {
return [];
}
}
export async function getDiscordMemberProfile(discordId: string) {
const cached = getCached(profileCache.get(discordId));
if (cached) return cached;
const token = process.env.DISCORD_BOT_TOKEN;
const guildId = process.env.DISCORD_GUILD_ID;
if (!token || !guildId || !discordId) return null;
try {
const [memberRes, allRoles] = await Promise.all([
fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${discordId}`, {
headers: { Authorization: `Bot ${token}` },
cache: "no-store",
signal: AbortSignal.timeout(5000),
}),
getGuildRolesInternal(),
]);
if (!memberRes.ok) return null;
const member = await memberRes.json();
// Map member role IDs to actual role objects
const memberRoleIds = member.roles || [];
const roles = memberRoleIds
.map((id: string) => allRoles.find(r => r.id === id))
.filter(Boolean);
// Sort roles by position (descending)
roles.sort((a: any, b: any) => (b?.position || 0) - (a?.position || 0));
const user = member.user;
let avatarUrl = null;
if (user.avatar) {
const ext = user.avatar.startsWith("a_") ? "gif" : "png";
avatarUrl = `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.${ext}?size=128`;
}
let bannerUrl = null;
if (user.banner) {
const ext = user.banner.startsWith("a_") ? "gif" : "png";
bannerUrl = `https://cdn.discordapp.com/banners/${user.id}/${user.banner}.${ext}?size=512`;
}
const result = {
id: user.id,
username: user.username,
globalName: user.global_name || null,
avatarUrl,
bannerUrl,
accentColor: user.accent_color || null,
roles,
};
profileCache.set(discordId, { data: result, expiresAt: Date.now() + CACHE_TTL });
return result;
} catch (e) {
console.error("Failed to fetch member profile", e);
return null;
}
}
+20 -24
View File
@@ -7,17 +7,8 @@ import { questions } from "@/db/schema";
import type { QuestionType } from "@/db/schema";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
async function assertAdmin() {
const session = await getServerSession(authOptions);
const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const discordId = (session?.user as { discordId?: string } | undefined)?.discordId;
if (!discordId || !adminIds.includes(discordId)) {
throw new Error("Unauthorized");
}
}
import { requireAdmin } from "@/lib/auth";
import { sanitizeHtml } from "@/lib/sanitize-html";
export async function createQuestion(
formId: string,
@@ -29,18 +20,18 @@ export async function createQuestion(
options?: string[];
}
) {
await assertAdmin();
await requireAdmin();
await db.insert(questions).values({
formId,
type: data.type,
label: data.label,
label: sanitizeHtml(data.label),
required: data.required,
displayOrder: data.displayOrder,
options: data.options ?? [],
allowOther: false,
});
revalidatePath("/form");
revalidatePath("/form/admin");
revalidatePath("/admin/form");
}
export async function updateQuestion(
@@ -56,7 +47,7 @@ export async function updateQuestion(
allowOther: boolean;
}>
) {
await assertAdmin();
await requireAdmin();
if (data.imageUrl !== undefined) {
const q = await db.query.questions.findFirst({
where: (q, { eq }) => eq(q.id, id),
@@ -65,9 +56,14 @@ export async function updateQuestion(
await deleteImageFile(q.imageUrl);
}
}
await db.update(questions).set(data).where(eq(questions.id, id));
const nextData = {
...data,
label: data.label === undefined ? undefined : sanitizeHtml(data.label),
};
await db.update(questions).set(nextData).where(eq(questions.id, id));
revalidatePath("/form");
revalidatePath("/form/admin");
revalidatePath("/admin/form");
}
export async function bulkUpdateQuestions(
@@ -82,7 +78,7 @@ export async function bulkUpdateQuestions(
allowOther: boolean;
}[]
) {
await assertAdmin();
await requireAdmin();
// Use Promise.all to update all questions concurrently
await Promise.all(
@@ -90,7 +86,7 @@ export async function bulkUpdateQuestions(
db
.update(questions)
.set({
label: u.label,
label: sanitizeHtml(u.label),
type: u.type,
required: u.required,
displayOrder: u.displayOrder,
@@ -102,7 +98,7 @@ export async function bulkUpdateQuestions(
);
revalidatePath("/form");
revalidatePath("/form/admin");
revalidatePath("/admin/form");
}
async function deleteImageFile(imageUrl: string | null) {
@@ -119,7 +115,7 @@ async function deleteImageFile(imageUrl: string | null) {
}
export async function deleteQuestion(id: string, formId: string) {
await assertAdmin();
await requireAdmin();
const q = await db.query.questions.findFirst({
where: (q, { eq }) => eq(q.id, id),
});
@@ -128,14 +124,14 @@ export async function deleteQuestion(id: string, formId: string) {
}
await db.delete(questions).where(eq(questions.id, id));
revalidatePath("/form");
revalidatePath("/form/admin");
revalidatePath(`/admin/form/${formId}`);
}
export async function reorderQuestions(
formId: string,
orderedIds: string[]
) {
await assertAdmin();
await requireAdmin();
await Promise.all(
orderedIds.map((id, index) =>
db
@@ -145,5 +141,5 @@ export async function reorderQuestions(
)
);
revalidatePath("/form");
revalidatePath("/form/admin");
revalidatePath(`/admin/form/${formId}`);
}
+39 -53
View File
@@ -6,6 +6,7 @@ import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { requireAdmin, requireDiscordId, requireFormAccess } from "@/lib/auth";
export async function submitForm(
formId: string,
@@ -14,85 +15,76 @@ export async function submitForm(
const session = await getServerSession(authOptions);
if (!session?.user) throw new Error("Not authenticated");
const discordId = (session.user as { discordId?: string }).discordId;
if (!discordId) throw new Error("No Discord ID found");
const form = await db.query.forms.findFirst({
where: (f, { eq }) => eq(f.id, formId),
});
if (!form) throw new Error("Form not found");
const discordId = await requireDiscordId();
const userName = session.user.name ?? null;
const form = await requireFormAccess(formId, discordId);
if (!form.isOpen) throw new Error("This form is disabled for now");
const existingSubmission = await db.query.submissions.findFirst({
where: (s, { eq, and }) =>
and(eq(s.formId, formId), eq(s.userDiscordId, discordId)),
const result = await db.transaction(async (tx) => {
const existingSubmission = await tx.query.submissions.findFirst({
where: (submission, { eq, and }) =>
and(eq(submission.formId, formId), eq(submission.userDiscordId, discordId)),
});
let submissionId = "";
const isUpdate = !!existingSubmission;
const isUpdate = Boolean(existingSubmission);
let oldAnswersDict: Record<string, string> = {};
if (existingSubmission) {
submissionId = existingSubmission.id;
// Fetch old answers
const oldAnswersList = await db.query.answers.findMany({
where: (a, { eq }) => eq(a.submissionId, submissionId),
const oldAnswersList = await tx.query.answers.findMany({
where: (answer, { eq }) => eq(answer.submissionId, submissionId),
});
oldAnswersDict = oldAnswersList.reduce((acc, curr) => {
oldAnswersDict = oldAnswersList.reduce<Record<string, string>>((acc, curr) => {
acc[curr.questionId] = curr.value;
return acc;
}, {} as Record<string, string>);
}, {});
// Ensure editHistory is an array
let history = existingSubmission.editHistory;
if (!Array.isArray(history)) {
history = [];
}
// Push the old answers to history
const history = Array.isArray(existingSubmission.editHistory)
? [...existingSubmission.editHistory]
: [];
history.push({
editedAt: new Date().toISOString(),
oldAnswers: oldAnswersDict,
});
// Update submission record
await db
await tx
.update(submissions)
.set({
editHistory: history,
})
.set({ editHistory: history })
.where(eq(submissions.id, submissionId));
// Delete old answers
await db.delete(answers).where(eq(answers.submissionId, submissionId));
await tx.delete(answers).where(eq(answers.submissionId, submissionId));
} else {
// Insert new submission
const [inserted] = await db
const [inserted] = await tx
.insert(submissions)
.values({
formId,
userDiscordId: discordId,
userName: session.user.name ?? null,
userName,
})
.returning();
submissionId = inserted.id;
}
// Insert new/updated answers
if (answersList.length > 0) {
await db.insert(answers).values(
answersList.map((a) => ({
await tx.insert(answers).values(
answersList.map((answer) => ({
submissionId,
questionId: a.questionId,
value: a.value,
questionId: answer.questionId,
value: answer.value,
}))
);
}
return { submissionId, isUpdate, oldAnswersDict };
});
// Send Discord Webhook
const templateToUse = isUpdate ? (form as any).discordWebhookUpdateTemplate : form.discordWebhookTemplate;
const templateToUse = result.isUpdate
? form.discordWebhookUpdateTemplate
: form.discordWebhookTemplate;
if (form.discordWebhookUrl && templateToUse) {
try {
@@ -114,10 +106,10 @@ export async function submitForm(
}, {} as Record<string, string>);
let updatesText = "";
if (isUpdate) {
if (result.isUpdate) {
const changed: string[] = [];
for (const ans of answersList) {
const oldVal = oldAnswersDict[ans.questionId];
const oldVal = result.oldAnswersDict[ans.questionId];
if (oldVal !== ans.value) {
const qTitle = questionsList.find(q => q.id === ans.questionId)?.label || `Question`;
changed.push(`**${qTitle}**: \`${oldVal || "Empty"}\` ➡️ \`${ans.value || "Empty"}\``);
@@ -156,22 +148,20 @@ export async function submitForm(
}
}
revalidatePath("/form/admin/result");
revalidatePath(`/admin/form/${formId}/result`);
return { ok: true };
}
export async function deleteSubmission(id: string) {
const session = await getServerSession(authOptions);
if (!session?.user) throw new Error("Not authenticated");
await requireAdmin();
await db.delete(submissions).where(eq(submissions.id, id));
revalidatePath("/form/admin/result");
revalidatePath("/admin/form");
return { ok: true };
}
export async function deleteAllSubmissions(formId: string) {
const session = await getServerSession(authOptions);
if (!session?.user) throw new Error("Not authenticated");
await requireAdmin();
await db.delete(submissions).where(eq(submissions.formId, formId));
revalidatePath(`/admin/form/${formId}/result`);
@@ -179,11 +169,7 @@ export async function deleteAllSubmissions(formId: string) {
}
export async function deleteOwnSubmission(formId: string) {
const session = await getServerSession(authOptions);
if (!session?.user) throw new Error("Not authenticated");
const discordId = (session.user as { discordId?: string }).discordId;
if (!discordId) throw new Error("No Discord ID found");
const discordId = await requireDiscordId();
const { and } = await import("drizzle-orm");
@@ -194,6 +180,6 @@ export async function deleteOwnSubmission(formId: string) {
)
);
revalidatePath(`/form`);
revalidatePath("/form/admin/result");
revalidatePath(`/admin/form/${formId}/result`);
return { ok: true };
}
+7 -4
View File
@@ -3,6 +3,8 @@
import { revalidateTag } from "next/cache";
const validPlatforms = ["youtube", "roblox", "discord", "tiktok"];
type FollowerFetcher = () => Promise<unknown>;
export async function refetchPlatform(
platform: string
): Promise<{ count: number | null }> {
@@ -15,10 +17,10 @@ export async function refetchPlatform(
let tiktokProfile = null;
try {
const mod = await import(`@/lib/followers/${platform}`);
const mod = (await import(`@/lib/followers/${platform}`)) as Record<string, unknown>;
const fns = Object.entries(mod).filter(
([, v]) => typeof v === "function"
) as [string, Function][];
(entry): entry is [string, FollowerFetcher] => typeof entry[1] === "function"
);
// Prefer function with "Count" or "Member" in name
const countFn = fns.find(([name]) => /count|member/i.test(name));
@@ -33,7 +35,8 @@ export async function refetchPlatform(
} else if (
result &&
typeof result === "object" &&
"followerCount" in result
"followerCount" in result &&
(typeof result.followerCount === "number" || result.followerCount === null)
) {
count = result.followerCount;
tiktokProfile = result;
+12 -8
View File
@@ -39,6 +39,7 @@ import { RichTextEditor } from "@/components/rich-text-editor";
import { FormattedText } from "@/components/formatted-text";
import { ThemeToggle } from "@/components/theme-toggle";
import { DropdownMenuAvatar, DiscordMemberProfile } from "@/components/dropdown-menu-avatar";
import { getErrorMessage } from "@/lib/errors";
interface Question {
id: string;
@@ -84,10 +85,12 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
const formIsOpenRef = useRef(formIsOpen);
const questionsRef = useRef(questions);
useEffect(() => {
formTitleRef.current = formTitle;
formDescriptionRef.current = formDescription;
formIsOpenRef.current = formIsOpen;
questionsRef.current = questions;
}, [formDescription, formIsOpen, formTitle, questions]);
const autoSaveForm = useCallback(() => {
if (formTimerRef.current) clearTimeout(formTimerRef.current);
@@ -170,7 +173,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
options: [],
});
router.refresh();
} catch (err: any) {
} catch (err: unknown) {
console.error("Failed to add question:", err);
}
};
@@ -186,7 +189,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
try {
await deleteQuestion(id, form.id);
router.refresh();
} catch (err: any) {
} catch (err: unknown) {
console.error("Failed to delete question:", err);
}
};
@@ -243,7 +246,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
{
loading: "Uploading image...",
success: "Image uploaded!",
error: (err: any) => err.message || "Failed to upload image.",
error: (err: unknown) => getErrorMessage(err, "Failed to upload image."),
}
);
};
@@ -259,7 +262,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
{
loading: "Removing image...",
success: "Image removed.",
error: (err: any) => err.message || "Failed to remove image.",
error: (err: unknown) => getErrorMessage(err, "Failed to remove image."),
}
);
};
@@ -283,8 +286,9 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
handleUpdateQuestion(question.id, { options });
};
React.useEffect(() => {
setQuestions(initialQuestions);
useEffect(() => {
const timeout = setTimeout(() => setQuestions(initialQuestions), 0);
return () => clearTimeout(timeout);
}, [initialQuestions]);
return (
@@ -360,7 +364,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
<div className="flex items-center gap-2 self-end sm:self-auto">
<Select
value={q.type}
onValueChange={(val: any) => handleUpdateQuestion(q.id, { type: val })}
onValueChange={(val) => handleUpdateQuestion(q.id, { type: val as Question["type"] })}
>
<SelectTrigger className="w-36">
<SelectValue />
@@ -499,7 +503,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
onCheckedChange={(checked) => handleUpdateQuestion(q.id, { allowOther: checked })}
/>
<Label htmlFor={`allowOther-${q.id}`} className="text-sm font-normal text-muted-foreground">
Add 'Other' option
Add &apos;Other&apos; option
</Label>
</div>
)}
+20 -9
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useTransition, useEffect, useRef } from "react";
import { useCallback, useState, useTransition, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -10,7 +10,8 @@ import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { updateForm } from "@/app/admin/form/actions";
import { Loader2, ChevronDown, Check } from "lucide-react";
import { getGuildRoles, DiscordRole } from "@/app/actions/discord";
import { getGuildRoles } from "@/app/actions/discord";
import type { DiscordRole } from "@/lib/discord";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@@ -18,6 +19,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area"
import { getErrorMessage } from "@/lib/errors";
interface FormExtraClientProps {
form: {
@@ -43,7 +45,7 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
const [roles, setRoles] = useState<DiscordRole[]>([]);
const [loadingRoles, setLoadingRoles] = useState(true);
const [isSaving, startSaveTransition] = useTransition();
const [, startSaveTransition] = useTransition();
const [saveStatus, setSaveStatus] = useState<AutoSaveStatus>("idle");
const [lastEdited, setLastEdited] = useState<"access" | "webhook" | null>(null);
@@ -57,7 +59,7 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
.finally(() => setLoadingRoles(false));
}, []);
const handleSave = (silent = false) => {
const handleSave = useCallback(() => {
setSaveStatus("saving");
startSaveTransition(async () => {
try {
@@ -71,12 +73,21 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
setSaveStatus("saved");
setTimeout(() => setSaveStatus("idle"), 2000);
router.refresh();
} catch (err: any) {
} catch (err: unknown) {
setSaveStatus("idle");
toast.error(err.message || "Failed to update settings.");
toast.error(getErrorMessage(err, "Failed to update settings."));
}
});
};
}, [
allowedRoles,
deniedRoles,
form.id,
router,
startSaveTransition,
webhookTemplate,
webhookUpdateTemplate,
webhookUrl,
]);
useEffect(() => {
if (isFirstRender.current) {
@@ -87,13 +98,13 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
if (saveTimeout.current) clearTimeout(saveTimeout.current);
saveTimeout.current = setTimeout(() => {
handleSave(true);
handleSave();
}, 1000);
return () => {
if (saveTimeout.current) clearTimeout(saveTimeout.current);
};
}, [webhookUrl, webhookTemplate, webhookUpdateTemplate, allowedRoles, deniedRoles]);
}, [handleSave]);
const toggleRole = (list: string[], setList: (v: string[]) => void, roleId: string) => {
setLastEdited("access");
+3 -2
View File
@@ -3,7 +3,8 @@ import { AdminSidebar } from "./client";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { getDiscordMemberProfile } from "@/app/actions/discord";
import { getSessionDiscordId } from "@/lib/auth";
import { getDiscordMemberProfile } from "@/lib/discord";
export const metadata: Metadata = {
title: {
@@ -22,7 +23,7 @@ export default async function AdminFormLayout(
const session = await getServerSession(authOptions);
let profile = null;
const discordId = (session?.user as any)?.discordId;
const discordId = getSessionDiscordId(session);
if (discordId) {
profile = await getDiscordMemberProfile(discordId);
}
@@ -1,9 +1,8 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import React, { useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
ContextMenu,
ContextMenuTrigger,
@@ -14,7 +13,7 @@ import {
ContextMenuGroup
} from "@/components/ui/context-menu";
import { Copy, Check, Users, Calendar, Clock } from "lucide-react";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { ScrollArea } from "@/components/ui/scroll-area";
import Image from 'next/image';
import { stripHtml } from "@/lib/utils";
import { HtmlDisplay } from "@/components/html-display";
+31 -25
View File
@@ -5,49 +5,55 @@ import { forms } from "@/db/schema";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { requireAdmin } from "@/lib/auth";
import { sanitizeHtml } from "@/lib/sanitize-html";
export async function createForm(formData: FormData) {
const session = await getServerSession(authOptions);
const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const discordId = (session?.user as { discordId?: string } | undefined)?.discordId;
if (!discordId || !adminIds.includes(discordId)) {
throw new Error("Unauthorized");
}
await requireAdmin();
const title = formData.get("title") as string;
const description = formData.get("description") as string | null;
const [form] = await db
.insert(forms)
.values({ title, description: description ?? null })
.values({ title, description: description ? sanitizeHtml(description) : null })
.returning();
revalidatePath("/form/admin");
redirect(`/form/admin/${form.id}/edit`);
revalidatePath("/admin/form");
redirect(`/admin/form/${form.id}`);
}
export async function createDefaultForm() {
await requireAdmin();
const [form] = await db
.insert(forms)
.values({ title: "New Form", description: "" })
.returning();
revalidatePath("/admin/form");
redirect(`/admin/form/${form.id}`);
}
export async function updateForm(id: string, data: { title?: string; description?: string | null; isOpen?: boolean; discordWebhookUrl?: string | null; discordWebhookTemplate?: string | null; discordWebhookUpdateTemplate?: string | null; allowedRoles?: string[]; deniedRoles?: string[] }) {
const session = await getServerSession(authOptions);
const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const discordId = (session?.user as { discordId?: string } | undefined)?.discordId;
if (!discordId || !adminIds.includes(discordId)) {
throw new Error("Unauthorized");
}
await requireAdmin();
await db.update(forms).set(data).where(eq(forms.id, id));
const nextData = {
...data,
description: data.description === undefined
? undefined
: data.description
? sanitizeHtml(data.description)
: data.description,
};
await db.update(forms).set(nextData).where(eq(forms.id, id));
revalidatePath("/form");
revalidatePath("/form/admin");
revalidatePath("/admin/form");
}
export async function deleteForm(id: string) {
const session = await getServerSession(authOptions);
const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const discordId = (session?.user as { discordId?: string } | undefined)?.discordId;
if (!discordId || !adminIds.includes(discordId)) {
throw new Error("Unauthorized");
}
await requireAdmin();
await db.delete(forms).where(eq(forms.id, id));
revalidatePath("/admin/form");
+2 -10
View File
@@ -11,9 +11,9 @@ import {
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Plus } from "lucide-react";
import { redirect } from "next/navigation";
import { DeleteFormButton } from "@/components/delete-buttons";
import { AdminShellServer } from "@/components/admin-shell-server";
import { createDefaultForm } from "./actions";
export const dynamic = "force-dynamic";
@@ -32,15 +32,7 @@ export default async function AdminFormsPage() {
Manage your custom forms and view submissions.
</p>
</div>
<form action={async () => {
"use server";
const { forms } = await import("@/db/schema");
const [newForm] = await db.insert(forms).values({
title: "New Form",
description: "",
}).returning();
redirect(`/admin/form/${newForm.id}`);
}}>
<form action={createDefaultForm}>
<Button type="submit">
<Plus className="mr-2 h-4 w-4" />
Create Form
+3 -6
View File
@@ -1,6 +1,7 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { getAdminDiscordIds, getSessionDiscordId } from "@/lib/auth";
export default async function AdminLayout({
children,
@@ -8,13 +9,9 @@ export default async function AdminLayout({
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const discordId = (session?.user as any)?.discordId;
const discordId = getSessionDiscordId(session);
if (!discordId || !adminIds.includes(discordId)) {
if (!discordId || !getAdminDiscordIds().includes(discordId)) {
redirect("/");
}
+3 -2
View File
@@ -3,6 +3,7 @@ import DiscordProvider from "next-auth/providers/discord";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/db";
import { accounts, sessions, users, verificationTokens } from "@/db/schema";
import type { SessionWithDiscord } from "@/lib/auth";
const discordIdCache = new Map<string, string>();
@@ -22,7 +23,7 @@ export const authOptions: NextAuthOptions = {
callbacks: {
async session({ session, user }) {
if (session.user) {
(session.user as any).id = user.id;
(session as SessionWithDiscord).user!.id = user.id;
let discordId = discordIdCache.get(user.id);
if (!discordId) {
@@ -37,7 +38,7 @@ export const authOptions: NextAuthOptions = {
}
}
if (discordId) {
(session.user as typeof session.user & { discordId: string }).discordId = discordId;
(session as SessionWithDiscord).user!.discordId = discordId;
}
}
return session;
+13 -13
View File
@@ -1,18 +1,13 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { requireAdmin } from "@/lib/auth";
const MAX_UPLOAD_BYTES = 1024 * 1024;
export async function POST(req: Request) {
try {
const session = await getServerSession(authOptions);
const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const discordId = (session?.user as { discordId?: string } | undefined)
?.discordId;
if (!discordId || !adminIds.includes(discordId)) {
try {
await requireAdmin();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
@@ -27,6 +22,10 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Only image files are allowed" }, { status: 400 });
}
if (file.size > MAX_UPLOAD_BYTES) {
return NextResponse.json({ error: "Image must be 1MB or smaller" }, { status: 400 });
}
const allowedExtensions = ["png", "jpg", "jpeg", "webp", "gif"];
const originalExt = (file.name.split(".").pop() || "").toLowerCase();
@@ -40,8 +39,9 @@ export async function POST(req: Request) {
const dataUri = `data:${mimeType};base64,${base64}`;
return NextResponse.json({ url: dataUri });
} catch (error: any) {
} catch (error) {
console.error("Upload error:", error);
return NextResponse.json({ error: error.message || "Internal server error" }, { status: 500 });
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+7 -12
View File
@@ -3,28 +3,23 @@
import Link from "next/link";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";
import { useState } from "react";
import { Spinner } from "@/components/ui/spinner";
import Background from "@/public/404.png";
import Profile from "@/public/404-image.jpg";
import { Activity, Home } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
export default function NotFound() {
const [goingHome, goHome] = useState(false);
const [message, setMessage] = useState("");
useEffect(() => {
const messages = [
const MESSAGES = [
"ฟอร์มนี้อาจจะถูก Erika กินไปแล้ว...",
"หรือว่าฟอร์มนี้โดน Sukuna เล่นไปแล้ว...",
"ฟอร์มนี้อาจจะหมดอายุหรือโดนลบไปแล้ว...",
"บางทีคนสร้างอาจจะลืมเปิดฟอร์มนะ...",
"ฟอร์มหายไปไหนน้า... ลองเช็คลิงก์ดูอีกทีสิ",
];
];
setMessage(messages[Math.floor(Math.random() * messages.length)]);
}, []);
export default function NotFound() {
const [goingHome, goHome] = useState(false);
const [message] = useState(() => MESSAGES[Math.floor(Math.random() * MESSAGES.length)]);
return (
<div className="relative flex min-h-screen w-full flex-col items-center justify-center gap-2">
@@ -45,7 +40,7 @@ export default function NotFound() {
/>
</div>
<h2 className="text-2xl font-semibold">404 ไม่พบหน้านี้</h2>
<span>{message || <Skeleton className="h-6 w-40" />}</span>
<span>{message}</span>
<div className="flex gap-2">
<Link href="/">
<Button
+4 -26
View File
@@ -3,8 +3,8 @@ import { FormFillerClient } from "../client";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { notFound } from "next/navigation";
import { getGuildMemberRoles } from "@/app/actions/discord";
import LoginDialog from "@/components/login-dialog";
import { canAccessForm, getSessionDiscordId } from "@/lib/auth";
export const dynamic = "force-dynamic";
@@ -15,7 +15,7 @@ export default async function FormsSubmitPage(
) {
const params = await props.params;
const session = await getServerSession(authOptions);
const discordId = (session?.user as { discordId?: string })?.discordId;
const discordId = getSessionDiscordId(session);
const form = await db.query.forms.findFirst({
where: (f, { eq }) => eq(f.id, params.id),
@@ -25,38 +25,16 @@ export default async function FormsSubmitPage(
return notFound();
}
const allowedRoles = form.allowedRoles || [];
const deniedRoles = form.deniedRoles || [];
const hasRoleRestrictions = allowedRoles.length > 0 || deniedRoles.length > 0;
if (hasRoleRestrictions) {
if (!discordId) {
if (!(await canAccessForm(form, discordId))) {
return notFound();
}
const userRoles = await getGuildMemberRoles(discordId);
let isAllowed = allowedRoles.length === 0;
if (allowedRoles.length > 0) {
isAllowed = userRoles.some(r => allowedRoles.includes(r));
}
let isDenied = false;
if (deniedRoles.length > 0) {
isDenied = userRoles.some(r => deniedRoles.includes(r));
}
if (!isAllowed || isDenied) {
return notFound();
}
}
const questionsList = await db.query.questions.findMany({
where: (q, { eq }) => eq(q.formId, form.id),
orderBy: (q, { asc }) => [asc(q.displayOrder)],
});
let initialAnswers: Record<string, string> = {};
const initialAnswers: Record<string, string> = {};
if (discordId) {
const existingSubmission = await db.query.submissions.findFirst({
where: (s, { eq, and }) =>
+19 -22
View File
@@ -2,7 +2,7 @@
import React, { useState, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@@ -16,11 +16,11 @@ import { Dialog, DialogContent, DialogTrigger, DialogTitle } from "@/components/
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { toast } from "sonner";
import { submitForm, deleteOwnSubmission } from "@/app/actions/submissions";
import { Loader2, LogOut } from "lucide-react";
import { signIn, signOut } from "next-auth/react";
import { Loader2 } from "lucide-react";
import { signIn } from "next-auth/react";
import Image from "next/image";
import { z } from "zod";
import { useForm, Controller } from "react-hook-form";
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
@@ -30,6 +30,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { DropdownMenuAvatar, DiscordMemberProfile } from "@/components/dropdown-menu-avatar";
import { getErrorMessage } from "@/lib/errors";
export function FormLayoutClient({
userName,
@@ -81,11 +82,11 @@ interface FormFillerClientProps {
}
const LS_KEY = (formId: string) => `form-draft-${formId}`;
type FormValues = Record<string, string | string[] | undefined>;
const buildSchema = (questions: Question[]) => {
const shape: Record<string, z.ZodTypeAny> = {};
questions.forEach(q => {
const plainLabel = stripHtml(q.label);
if (q.type === "checkbox") {
shape[q.id] = q.required
? z.array(z.string()).min(1, `จำเป็นต้องตอบข้อนี้`)
@@ -107,15 +108,15 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
const { defaultValues, initialOtherValues } = useMemo(() => {
const serverAnswers = { ...initialAnswers };
let draft: Record<string, any> = {};
let draft: FormValues = {};
try {
const raw = localStorage.getItem(LS_KEY(form.id));
if (raw) draft = JSON.parse(raw);
if (raw) draft = JSON.parse(raw) as FormValues;
} catch {}
const merged = { ...draft, ...serverAnswers };
const vals: Record<string, any> = {};
const vals: FormValues = {};
const others: Record<string, string> = {};
questions.forEach(q => {
@@ -136,7 +137,7 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
}
vals[q.id] = arr;
} else if (q.type === "radio") {
let val = ans || "";
let val = typeof ans === "string" ? ans : "";
if (q.allowOther && q.options && val !== "" && !q.options.includes(val)) {
if (val !== "__OTHER__") {
others[q.id] = val;
@@ -145,7 +146,7 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
}
vals[q.id] = val;
} else {
vals[q.id] = ans || "";
vals[q.id] = typeof ans === "string" ? ans : "";
}
});
return { defaultValues: vals, initialOtherValues: others };
@@ -160,16 +161,14 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
mode: "onTouched",
});
const { handleSubmit, control, watch, formState: { isSubmitting }, trigger, reset } = methods;
const { handleSubmit, control, formState: { isSubmitting }, reset } = methods;
const watchedValues = useWatch({ control });
useEffect(() => {
const subscription = watch((value) => {
try {
localStorage.setItem(LS_KEY(form.id), JSON.stringify(value));
localStorage.setItem(LS_KEY(form.id), JSON.stringify(watchedValues));
} catch {}
});
return () => subscription.unsubscribe();
}, [watch, form.id]);
}, [form.id, watchedValues]);
@@ -206,9 +205,9 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
router.refresh();
}
}
} catch (err: any) {
} catch (err: unknown) {
console.error(err);
toast.error(err.message || "ไม่สามารถส่งฟอร์มได้");
toast.error(getErrorMessage(err, "ไม่สามารถส่งฟอร์มได้"));
}
};
@@ -221,9 +220,9 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
reset({});
router.refresh();
}
} catch (err: any) {
} catch (err: unknown) {
console.error(err);
toast.error(err.message || "ไม่สามารถลบข้อมูลได้");
toast.error(getErrorMessage(err, "ไม่สามารถลบข้อมูลได้"));
}
};
@@ -502,5 +501,3 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
</div>
);
}
+3 -2
View File
@@ -3,7 +3,8 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { FormLayoutClient, LoginButton } from "./client";
import { Metadata } from "next";
import { ThemeToggle } from "@/components/theme-toggle";
import { getDiscordMemberProfile } from "@/app/actions/discord";
import { getSessionDiscordId } from "@/lib/auth";
import { getDiscordMemberProfile } from "@/lib/discord";
import Link from 'next/link';
const siteUrl = process.env.BASE_URL;
@@ -35,7 +36,7 @@ export default async function FormLayout({
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
const discordId = (session?.user as any)?.discordId;
const discordId = getSessionDiscordId(session);
let profile = null;
if (discordId) {
+6 -37
View File
@@ -4,12 +4,11 @@ import {
Card,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { HtmlDisplay } from "@/components/html-display";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { getGuildMemberRoles } from "@/app/actions/discord";
import { canAccessForm, getSessionDiscordId } from "@/lib/auth";
export const dynamic = "force-dynamic";
@@ -20,44 +19,14 @@ export default async function FormsListingPage() {
});
const session = await getServerSession(authOptions);
const discordId = (session?.user as { discordId?: string })?.discordId;
// Only fetch roles if at least one form has role restrictions
const hasRoleRestrictions = formsList.some(
(f) => (f.allowedRoles?.length ?? 0) > 0 || (f.deniedRoles?.length ?? 0) > 0
);
let userRoles: string[] = [];
if (discordId && hasRoleRestrictions) {
try {
userRoles = await getGuildMemberRoles(discordId);
} catch (e) {
console.error(e);
const discordId = getSessionDiscordId(session);
const visibleForms = [];
for (const form of formsList) {
if (await canAccessForm(form, discordId)) {
visibleForms.push(form);
}
}
const visibleForms = formsList.filter((form) => {
const allowedRoles = form.allowedRoles || [];
const deniedRoles = form.deniedRoles || [];
if (allowedRoles.length === 0 && deniedRoles.length === 0) return true;
// If user is not logged in but there are role restrictions, hide it.
if (!discordId) return false;
let isAllowed = allowedRoles.length === 0;
if (allowedRoles.length > 0) {
isAllowed = userRoles.some(r => allowedRoles.includes(r));
}
let isDenied = false;
if (deniedRoles.length > 0) {
isDenied = userRoles.some(r => deniedRoles.includes(r));
}
return isAllowed && !isDenied;
});
return (
<div className="max-w-4xl mx-auto p-4 md:p-6 space-y-6">
<h1 className="text-3xl font-bold tracking-tight">Form ทั้งหมด</h1>
+7 -12
View File
@@ -3,28 +3,23 @@
import Link from "next/link";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";
import { useState } from "react";
import { Spinner } from "@/components/ui/spinner";
import Background from "@/public/404.png";
import Profile from "@/public/404-image.jpg";
import { Activity, Home } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
export default function NotFound() {
const [goingHome, goHome] = useState(false);
const [message, setMessage] = useState("");
useEffect(() => {
const messages = [
const MESSAGES = [
"Erika กินหน้านี้ไปแล้ว...",
"หรือจริงๆแล้วหน้านี้จะโดน Sukuna เล่น...",
"อาจจะโดนต่อยจากคนด้านบนแล้วก็ได้...",
"หรือ Erika ตัวปลอมขะมีอยู่จริง...",
"หน้านี้อาจโดนErikaตัวปลอมทำลายไปแล้วนะ...",
];
];
setMessage(messages[Math.floor(Math.random() * messages.length)]);
}, []);
export default function NotFound() {
const [goingHome, goHome] = useState(false);
const [message] = useState(() => MESSAGES[Math.floor(Math.random() * MESSAGES.length)]);
return (
<div className="flex min-h-screen w-full flex-col items-center justify-center gap-2 bg-[#2225] backdrop-blur-md backdrop-grayscale">
@@ -45,7 +40,7 @@ export default function NotFound() {
/>
</div>
<h2 className="text-2xl font-semibold">404 ไม่พบหน้านี้</h2>
<span>{message || <Skeleton className="h-6 w-40" />}</span>
<span>{message}</span>
<div className="flex gap-2">
<Link href="/">
<Button
+3 -2
View File
@@ -1,11 +1,12 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { getDiscordMemberProfile } from "@/app/actions/discord";
import { getSessionDiscordId } from "@/lib/auth";
import { getDiscordMemberProfile } from "@/lib/discord";
import { AdminShell } from "@/components/admin-shell";
export async function AdminShellServer({ children }: { children: React.ReactNode }) {
const session = await getServerSession(authOptions);
const discordId = (session?.user as any)?.discordId;
const discordId = getSessionDiscordId(session);
let profile = null;
if (discordId) {
-1
View File
@@ -7,7 +7,6 @@ import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
SidebarMenu,
SidebarMenuItem,
+3 -2
View File
@@ -2,15 +2,16 @@
import React, { useState } from "react";
import { Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { copyToClipboard } from "@/lib/utils";
import { cn } from "@/lib/utils";
import type { VariantProps } from "class-variance-authority";
export interface CopyButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
text?: string | (() => string);
onCopy?: () => void;
iconOnly?: boolean;
variant?: any;
variant?: VariantProps<typeof buttonVariants>["variant"];
}
export const CopyButton = React.forwardRef<HTMLButtonElement, CopyButtonProps>(
+7 -6
View File
@@ -28,6 +28,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Spinner } from "@/components/ui/spinner";
import { getErrorMessage } from "@/lib/errors";
interface DeleteFormButtonProps {
formId: string;
@@ -46,9 +47,9 @@ export function DeleteFormButton({ formId }: DeleteFormButtonProps) {
setDeleting(false);
return "Form deleted successfully!";
},
error: (err: any) => {
error: (err: unknown) => {
setDeleting(false);
return err.message || "Failed to delete form.";
return getErrorMessage(err, "Failed to delete form.");
},
});
};
@@ -102,9 +103,9 @@ export function DeleteSubmissionButton({ id, redirectUrl }: { id: string, redire
setLoading(false);
return "Submission deleted";
},
error: (err: any) => {
error: (err: unknown) => {
setLoading(false);
return err.message || "Failed to delete submission";
return getErrorMessage(err, "Failed to delete submission");
},
});
};
@@ -146,9 +147,9 @@ export function DeleteAllSubmissionsButton({ formId }: { formId: string }) {
setLoading(false);
return "All submissions deleted";
},
error: (err: any) => {
error: (err: unknown) => {
setLoading(false);
return err.message || "Failed to delete submissions";
return getErrorMessage(err, "Failed to delete submissions");
},
});
};
+5 -10
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useState } from "react";
import { usePathname } from "next/navigation";
import Image from "next/image";
import { LogOut } from "lucide-react";
@@ -12,7 +12,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { DiscordRole } from "@/app/actions/discord";
import type { DiscordRole } from "@/lib/discord";
export interface DiscordMemberProfile {
id: string;
@@ -60,20 +60,15 @@ export function DropdownMenuAvatar({
const displayName = profile?.globalName || userName;
const username = profile?.username || userName;
const bannerStyle = useMemo(
() => ({
const bannerStyle = {
height: 100,
backgroundColor: profile?.accentColor
? toHex(profile.accentColor)
: "hsl(var(--muted))",
backgroundImage: profile?.bannerUrl
? `url(${profile.bannerUrl})`
: "none",
backgroundImage: profile?.bannerUrl ? `url(${profile.bannerUrl})` : "none",
backgroundSize: "cover" as const,
backgroundPosition: "center" as const,
}),
[profile?.accentColor, profile?.bannerUrl]
);
};
return (
<DropdownMenu>
+2 -1
View File
@@ -1,5 +1,6 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { sanitizeHtml } from "@/lib/sanitize-html"
export interface HtmlDisplayProps extends React.HTMLAttributes<HTMLDivElement> {
html?: string | null;
@@ -11,7 +12,7 @@ export function HtmlDisplay({ html, className, ...props }: HtmlDisplayProps) {
return (
<div
className={cn("prose prose-sm dark:prose-invert max-w-none [&>p]:m-0 leading-snug", className)}
dangerouslySetInnerHTML={{ __html: html }}
dangerouslySetInnerHTML={{ __html: sanitizeHtml(html) }}
{...props}
/>
)
+11 -8
View File
@@ -1,6 +1,6 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { Suspense, useEffect, useRef, useState } from "react"
import { usePathname, useSearchParams } from "next/navigation"
import { Progress } from "@/components/ui/progress"
@@ -9,6 +9,7 @@ function PageProgressContent() {
const searchParams = useSearchParams()
const [progress, setProgress] = useState(0)
const [isVisible, setIsVisible] = useState(false)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Start progress on click
useEffect(() => {
@@ -39,9 +40,7 @@ function PageProgressContent() {
return prev + (95 - prev) * 0.1
})
}, 100)
// Store interval so we can clear it on route change
;(window as any).__page_progress_interval = interval
intervalRef.current = interval
}
}
@@ -51,17 +50,21 @@ function PageProgressContent() {
// Finish progress on route change
useEffect(() => {
if (typeof window !== "undefined" && (window as any).__page_progress_interval) {
clearInterval((window as any).__page_progress_interval)
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
if (isVisible) {
setProgress(100)
const finishTimeout = setTimeout(() => setProgress(100), 0)
const timeout = setTimeout(() => {
setIsVisible(false)
setTimeout(() => setProgress(0), 300) // Reset after hiding
}, 400)
return () => clearTimeout(timeout)
return () => {
clearTimeout(finishTimeout)
clearTimeout(timeout)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname, searchParams])
+13 -4
View File
@@ -10,6 +10,7 @@ import { Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIc
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { sanitizeHtml, sanitizeUrl } from "@/lib/sanitize-html";
interface RichTextEditorProps {
value: string;
@@ -18,7 +19,7 @@ interface RichTextEditorProps {
className?: string;
}
export function RichTextEditor({ value, onChange, placeholder, className }: RichTextEditorProps) {
export function RichTextEditor({ value, onChange, className }: RichTextEditorProps) {
const [isLinkDialogOpen, setIsLinkDialogOpen] = useState(false);
const [linkUrl, setLinkUrl] = useState("");
const [linkText, setLinkText] = useState("");
@@ -40,7 +41,7 @@ export function RichTextEditor({ value, onChange, placeholder, className }: Rich
// Get HTML output. If empty, maybe output empty string to avoid empty p tags.
let html = editor.getHTML();
if (html === "<p></p>") html = "";
onChange(html);
onChange(sanitizeHtml(html));
},
editorProps: {
attributes: {
@@ -71,10 +72,18 @@ export function RichTextEditor({ value, onChange, placeholder, className }: Rich
if (linkUrl === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
} else {
const safeUrl = sanitizeUrl(linkUrl);
if (!safeUrl) {
return;
}
if (linkText) {
editor.chain().focus().insertContent(`<a href="${linkUrl}">${linkText}</a>`).run();
editor.chain().focus().insertContent({
type: "text",
text: linkText,
marks: [{ type: "link", attrs: { href: safeUrl } }],
}).run();
} else {
editor.chain().focus().extendMarkRange("link").setLink({ href: linkUrl }).run();
editor.chain().focus().extendMarkRange("link").setLink({ href: safeUrl }).run();
}
}
setIsLinkDialogOpen(false);
+6 -1
View File
@@ -1,7 +1,12 @@
"use client"
import { ThemeProvider as NextThemesProvider, type ThemeProviderProps } from "next-themes"
import type { ReactNode } from "react"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
type Props = ThemeProviderProps & {
children: ReactNode;
}
export function ThemeProvider({ children, ...props }: Props) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
+2 -1
View File
@@ -31,7 +31,8 @@ export function ThemeToggle() {
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
const timeout = setTimeout(() => setMounted(true), 0)
return () => clearTimeout(timeout)
}, [])
// Once mounted, resolve "system" to its actual rendered theme for the icon
+12 -2
View File
@@ -5,6 +5,7 @@ import {
boolean,
integer,
jsonb,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { uuidv7 } from "uuidv7";
@@ -43,7 +44,9 @@ export const questions = formSchema.table("question", {
allowOther: boolean("allow_other").notNull().default(false),
});
export const submissions = formSchema.table("submission", {
export const submissions = formSchema.table(
"submission",
{
id: text("id")
.primaryKey()
.$defaultFn(() => uuidv7()),
@@ -56,7 +59,14 @@ export const submissions = formSchema.table("submission", {
editHistory: jsonb("edit_history")
.$type<{ editedAt: string; oldAnswers: Record<string, string> }[]>()
.default([]),
});
},
(submission) => [
uniqueIndex("submission_form_user_discord_id_unique").on(
submission.formId,
submission.userDiscordId
),
]
);
export const answers = formSchema.table("answer", {
id: text("id")
+1
View File
@@ -0,0 +1 @@
CREATE UNIQUE INDEX "submission_form_user_discord_id_unique" ON "form"."submission" USING btree ("form_id","user_discord_id");
+7
View File
@@ -78,6 +78,13 @@
"when": 1783089081925,
"tag": "0010_rainy_speedball",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1783613113217,
"tag": "0011_unique_submission_user",
"breakpoints": true
}
]
}
+5 -2
View File
@@ -11,8 +11,11 @@ export function useIsMobile() {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
const timeout = setTimeout(onChange, 0)
return () => {
clearTimeout(timeout)
mql.removeEventListener("change", onChange)
}
}, [])
return !!isMobile
+86
View File
@@ -0,0 +1,86 @@
import { getServerSession } from "next-auth";
import type { Session } from "next-auth";
import { db } from "@/db";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { getGuildMemberRoles } from "@/lib/discord";
export type SessionWithDiscord = Session & {
user?: Session["user"] & {
id?: string;
discordId?: string;
};
};
export type FormAccessConfig = {
id: string;
allowedRoles?: string[] | null;
deniedRoles?: string[] | null;
};
export function getAdminDiscordIds() {
return (process.env.ADMIN_DISCORD_IDS ?? "")
.split(",")
.map((id) => id.trim())
.filter(Boolean);
}
export function getSessionDiscordId(session: Session | null) {
return (session as SessionWithDiscord | null)?.user?.discordId ?? null;
}
export async function getCurrentDiscordId() {
const session = await getServerSession(authOptions);
return getSessionDiscordId(session);
}
export async function requireDiscordId() {
const discordId = await getCurrentDiscordId();
if (!discordId) {
throw new Error("Not authenticated");
}
return discordId;
}
export async function requireAdmin() {
const discordId = await requireDiscordId();
if (!getAdminDiscordIds().includes(discordId)) {
throw new Error("Unauthorized");
}
return discordId;
}
export async function canAccessForm(form: FormAccessConfig, discordId: string | null) {
const allowedRoles = form.allowedRoles ?? [];
const deniedRoles = form.deniedRoles ?? [];
if (allowedRoles.length === 0 && deniedRoles.length === 0) {
return true;
}
if (!discordId) {
return false;
}
const userRoles = await getGuildMemberRoles(discordId);
const isAllowed =
allowedRoles.length === 0 || userRoles.some((role) => allowedRoles.includes(role));
const isDenied = deniedRoles.some((role) => userRoles.includes(role));
return isAllowed && !isDenied;
}
export async function requireFormAccess(formId: string, discordId: string) {
const form = await db.query.forms.findFirst({
where: (forms, { eq }) => eq(forms.id, formId),
});
if (!form) {
throw new Error("Form not found");
}
if (!(await canAccessForm(form, discordId))) {
throw new Error("Unauthorized");
}
return form;
}
+151
View File
@@ -0,0 +1,151 @@
export interface DiscordRole {
id: string;
name: string;
color: number;
position: number;
}
export interface DiscordMemberProfile {
id: string;
username: string;
globalName: string | null;
avatarUrl: string | null;
bannerUrl: string | null;
accentColor: number | null;
roles: DiscordRole[];
}
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
const rolesCache: { entry: CacheEntry<DiscordRole[]> | null } = { entry: null };
const profileCache = new Map<string, CacheEntry<DiscordMemberProfile>>();
const CACHE_TTL = 60_000;
function getCached<T>(entry: CacheEntry<T> | null | undefined): T | null {
if (entry && Date.now() < entry.expiresAt) return entry.data;
return null;
}
export async function getGuildRolesInternal(): Promise<DiscordRole[]> {
const cached = getCached(rolesCache.entry);
if (cached) return cached;
const token = process.env.DISCORD_BOT_TOKEN;
const guildId = process.env.DISCORD_GUILD_ID;
if (!token || !guildId) return [];
try {
const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/roles`, {
headers: { Authorization: `Bot ${token}` },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return [];
const roles = (await res.json()) as Array<{
id: string;
name: string;
color: number;
position: number;
}>;
const result = roles
.map((role) => ({
id: role.id,
name: role.name,
color: role.color,
position: role.position,
}))
.sort((a, b) => b.position - a.position);
rolesCache.entry = { data: result, expiresAt: Date.now() + CACHE_TTL };
return result;
} catch {
return [];
}
}
export async function getGuildMemberRoles(discordId: string): Promise<string[]> {
const token = process.env.DISCORD_BOT_TOKEN;
const guildId = process.env.DISCORD_GUILD_ID;
if (!token || !guildId || !discordId) return [];
try {
const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${discordId}`, {
headers: { Authorization: `Bot ${token}` },
cache: "no-store",
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return [];
const member = (await res.json()) as { roles?: string[] };
return member.roles ?? [];
} catch {
return [];
}
}
export async function getDiscordMemberProfile(discordId: string): Promise<DiscordMemberProfile | null> {
const cached = getCached(profileCache.get(discordId));
if (cached) return cached;
const token = process.env.DISCORD_BOT_TOKEN;
const guildId = process.env.DISCORD_GUILD_ID;
if (!token || !guildId || !discordId) return null;
try {
const [memberRes, allRoles] = await Promise.all([
fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${discordId}`, {
headers: { Authorization: `Bot ${token}` },
cache: "no-store",
signal: AbortSignal.timeout(5000),
}),
getGuildRolesInternal(),
]);
if (!memberRes.ok) return null;
const member = (await memberRes.json()) as {
roles?: string[];
user: {
id: string;
username: string;
global_name?: string | null;
avatar?: string | null;
banner?: string | null;
accent_color?: number | null;
};
};
const roles = (member.roles ?? [])
.map((id) => allRoles.find((role) => role.id === id))
.filter((role): role is DiscordRole => Boolean(role))
.sort((a, b) => b.position - a.position);
const { user } = member;
const avatarUrl = user.avatar
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.${user.avatar.startsWith("a_") ? "gif" : "png"}?size=128`
: null;
const bannerUrl = user.banner
? `https://cdn.discordapp.com/banners/${user.id}/${user.banner}.${user.banner.startsWith("a_") ? "gif" : "png"}?size=512`
: null;
const result = {
id: user.id,
username: user.username,
globalName: user.global_name ?? null,
avatarUrl,
bannerUrl,
accentColor: user.accent_color ?? null,
roles,
};
profileCache.set(discordId, { data: result, expiresAt: Date.now() + CACHE_TTL });
return result;
} catch (error) {
console.error("Failed to fetch member profile", error);
return null;
}
}
+3
View File
@@ -0,0 +1,3 @@
export function getErrorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
+1 -1
View File
@@ -1,4 +1,4 @@
// @ts-ignore
// @ts-expect-error Bun exposes RedisClient at runtime.
import { RedisClient } from "bun";
const globalForRedis = global as unknown as { redis: RedisClient };
+79
View File
@@ -0,0 +1,79 @@
const ALLOWED_TAGS = new Set([
"a",
"b",
"blockquote",
"br",
"code",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"i",
"li",
"ol",
"p",
"pre",
"s",
"strong",
"strike",
"u",
"ul",
]);
const BLOCKED_TAGS = /<(script|style|iframe|object|embed|svg|math|template)[\s\S]*?<\/\1>/gi;
const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>/g;
const ATTR_RE = /([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+)/g;
export function sanitizeUrl(url: string) {
const trimmed = url.trim();
if (!trimmed) return "";
try {
const parsed = new URL(trimmed, "https://erika.sudloh.com");
if (parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.protocol === "mailto:") {
return trimmed;
}
} catch {
return "";
}
return "";
}
function escapeAttribute(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export function sanitizeHtml(html: string) {
return html.replace(BLOCKED_TAGS, "").replace(TAG_RE, (tag, rawName: string, rawAttrs: string) => {
const name = rawName.toLowerCase();
if (!ALLOWED_TAGS.has(name)) return "";
if (tag.startsWith("</")) {
return `</${name}>`;
}
if (name !== "a") {
return `<${name}>`;
}
let href = "";
for (const match of rawAttrs.matchAll(ATTR_RE)) {
if (match[1].toLowerCase() === "href") {
href = sanitizeUrl(match[2].replace(/^["']|["']$/g, ""));
break;
}
}
return href
? `<a href="${escapeAttribute(href)}" target="_blank" rel="noopener noreferrer">`
: "<a>";
});
}
-1
View File
@@ -2,7 +2,6 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["bun"],
// @ts-ignore
turbopack: {
root: process.cwd(),
},
+1 -2
View File
@@ -1,7 +1,6 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
export function proxy() {
// All /form routes are allowed through — auth is handled via dialog in the layout
return NextResponse.next();
}
+65
View File
@@ -0,0 +1,65 @@
import { readFile } from "fs/promises";
import postgres from "postgres";
const migrationTag = "0011_unique_submission_user";
const migrationPath = "drizzle/0011_unique_submission_user.sql";
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error("DATABASE_URL is not set");
}
const sql = postgres(databaseUrl, { max: 1 });
try {
const duplicates = await sql<{ form_id: string; user_discord_id: string; count: string }[]>`
select form_id, user_discord_id, count(*)::text as count
from form.submission
where user_discord_id is not null
group by form_id, user_discord_id
having count(*) > 1
limit 20
`;
if (duplicates.length > 0) {
console.error("Duplicate submissions would violate the unique index:");
for (const duplicate of duplicates) {
console.error(
`form_id=${duplicate.form_id} user_discord_id=${duplicate.user_discord_id} count=${duplicate.count}`
);
}
process.exit(1);
}
await sql`
create unique index if not exists "submission_form_user_discord_id_unique"
on "form"."submission" using btree ("form_id", "user_discord_id")
`;
const migrationTable = await sql<{ exists: boolean }[]>`
select to_regclass('drizzle.__drizzle_migrations') is not null as exists
`;
if (migrationTable[0]?.exists) {
const existing = await sql<{ count: string }[]>`
select count(*)::text as count
from drizzle.__drizzle_migrations
where hash = ${migrationTag}
`;
if (existing[0]?.count === "0") {
const migrationSql = await readFile(migrationPath, "utf8");
await sql`
insert into drizzle.__drizzle_migrations (hash, created_at)
values (${migrationTag}, ${Date.now()})
`;
console.log(`Applied ${migrationTag}: ${migrationSql.trim()}`);
} else {
console.log(`${migrationTag} was already recorded.`);
}
} else {
console.log("Applied index. Drizzle migration table was not found, so no migration row was recorded.");
}
} finally {
await sql.end();
}
+8 -5
View File
@@ -164,7 +164,9 @@ Erika is a Next.js website for the content creator "Erika!". It has two main fea
| `lib/config.ts` | **Linktree configuration for the root page (`/`).** Defines the profile (name, description, avatar) and the list of social links (Roblox, Discord, TikTok, YouTube) displayed on the landing page. Also defines `config_p2` which adds the Form link. This does NOT configure the form system. |
| `lib/icons.tsx` | SVG icon components for social platforms (Roblox, Discord, TikTok, YouTube, Form) |
| `lib/redis.ts` | Redis client singleton using Bun's built-in `RedisClient`. Used for caching follower counts (1hr TTL) and tracking unique visitors. |
| `lib/discord.ts` | Shared Discord webhook notification utilities |
| `lib/auth.ts` | Shared session, admin, and form role-access authorization helpers |
| `lib/discord.ts` | Shared Discord API utilities for guild roles/member profiles with in-memory TTL caching |
| `lib/sanitize-html.ts` | Sanitizes admin-authored rich text before storage/rendering |
| `lib/utils.ts` | Utility functions (e.g. `cn()` for className merging) |
### `lib/followers/` — Follower Count Fetchers
@@ -190,7 +192,7 @@ Erika is a Next.js website for the content creator "Erika!". It has two main fea
| File | Purpose |
|---|---|
| `app/actions/discord.ts` | Fetches Discord server roles natively via API for admin configuration and user access control |
| `app/actions/discord.ts` | Admin-protected server action for loading Discord server roles in the form settings UI |
### `app/` — Routes
@@ -296,8 +298,10 @@ Erika is a Next.js website for the content creator "Erika!". It has two main fea
2. **Admin check**: Server-side only. Compares the logged-in user's `discordId` (from session) against the comma-separated `ADMIN_DISCORD_IDS` env var. The primary guard is **centralized** in `app/admin/layout.tsx` which protects all `/admin/*` routes. Additional per-action checks exist in:
- `app/admin/form/actions.ts` (form CRUD actions)
- `app/actions/questions.ts` (question CRUD actions)
- `app/actions/submissions.ts` (admin submission deletion actions)
- `app/api/upload/route.ts` (file uploads)
3. **No middleware.ts**: Modern Next.js replaces `middleware.ts` with `proxy.ts`. The project has a `proxy.ts` that matches `/form/:path*` but currently just passes through. Auth checks are done per-page/per-action in server components and server actions.
3. **Form role access check**: `lib/auth.ts` contains the shared role-access check. Public form pages use it before rendering restricted forms, and `submitForm()` repeats the same check before mutating data.
4. **No middleware.ts**: Modern Next.js replaces `middleware.ts` with `proxy.ts`. The project has a `proxy.ts` that matches `/form/:path*` but currently just passes through. Auth checks are done per-page/per-action in server components and server actions.
---
@@ -307,8 +311,7 @@ The following optimizations have been implemented to keep CPU and RAM usage extr
1. **DB Connection Pooling**: `db/index.ts` uses a `globalThis` singleton to prevent connection leaks during dev hot-reloads, with a strict max pool size (`max: 5`) to reduce idle RAM.
2. **Session Caching**: The NextAuth session callback uses an in-memory `Map` with a TTL to cache the `discordId` lookup. This prevents a DB hit on *every single request* that checks the session.
3. **Discord API Caching**: `app/actions/discord.ts` aggressively caches Discord API responses (roles, profiles) in memory with a 60s TTL. This avoids massive delays (30s-50s) on admin pages caused by redundant external API requests.
3. **Discord API Caching**: `lib/discord.ts` aggressively caches Discord API responses (roles, profiles) in memory with a 60s TTL. This avoids massive delays (30s-50s) on admin pages caused by redundant external API requests.
4. **Redis Batching**: The unique visitor tracking (`app/page.tsx`) only runs the `scard` (count) command if `sadd` (add IP) actually added a new IP.
5. **Single Docker Replica**: The project runs on a **single replica** (`replicas: 1` in docker-compose.yml). Given Bun's performance, 1 replica is sufficient and drastically reduces the total RAM footprint compared to running 3 replicas.
6. **Nginx Streaming**: Nginx must have `proxy_buffering off;` (or specifically disabled for the Next.js upstream) so that Next.js App Router streaming (React Server Components and Server Actions) works without hanging or causing 504 timeouts.