Harden form auth and submission handling
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 'Other' option
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
CREATE UNIQUE INDEX "submission_form_user_discord_id_unique" ON "form"."submission" USING btree ("form_id","user_discord_id");
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function getErrorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
+1
-1
@@ -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 };
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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>";
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["bun"],
|
||||
// @ts-ignore
|
||||
turbopack: {
|
||||
root: process.cwd(),
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user