refactor: update Docker base images, enhance image upload security, and implement shared form client logic and validation

This commit is contained in:
2026-08-01 15:57:55 +07:00 Unverified
parent e243a75d33
commit b557879928
14 changed files with 505 additions and 318 deletions
+5 -95
View File
@@ -18,7 +18,7 @@ import {
deleteQuestion,
bulkUpdateQuestions,
} from "@/app/actions/questions";
import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Image as ImageIcon, X, Check, GripVertical, AlertCircle } from "lucide-react";
import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Image as ImageIcon, X } from "lucide-react";
import Image from 'next/image';
import { format, parseISO } from "date-fns";
import { DateTimePicker } from "@/components/date-time-picker";
@@ -43,13 +43,10 @@ import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
useDebouncedAutoSave,
type AutoSaveStatus,
} from "@/hooks/use-debounced-autosave";
import { stripHtml } from "@/lib/utils";
import {
@@ -76,6 +73,10 @@ import {
getQuestionResponseType,
type QuestionResponseType,
} from "@/lib/question-response-type";
import {
SaveStatusIndicator,
SortableQuestion,
} from "@/components/admin-form-editor-parts";
interface Question {
id: string;
@@ -765,53 +766,6 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
);
}
function SaveStatusIndicator({
status,
error,
onRetry,
}: {
status: AutoSaveStatus;
error: string | null;
onRetry: () => Promise<void>;
}) {
if (status === "clean") return null;
return (
<span
className="flex items-center gap-1.5 text-xs text-muted-foreground"
aria-live="polite"
>
{status === "dirty" && "Unsaved"}
{status === "saving" && (
<>
<Loader2 className="animate-spin" />
Saving
</>
)}
{status === "saved" && (
<>
<Check />
Saved
</>
)}
{status === "error" && (
<>
<AlertCircle />
<span>{error || "Could not save."}</span>
<Button
variant="link"
size="sm"
className="h-auto px-1"
onClick={() => void onRetry().catch(() => undefined)}
>
Retry
</Button>
</>
)}
</span>
);
}
function questionTypeLabel(type: Question["type"]) {
return {
text: "Short answer",
@@ -820,47 +774,3 @@ function questionTypeLabel(type: Question["type"]) {
checkbox: "Choose multiple",
}[type];
}
function SortableQuestion({
id,
position,
children,
}: {
id: string;
position: number;
children: React.ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
<div
ref={setNodeRef}
className="relative"
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
}}
>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute left-2 top-4"
aria-label={`Drag question ${position} to reorder`}
{...attributes}
{...listeners}
>
<GripVertical />
</Button>
{children}
</div>
);
}
+31 -8
View File
@@ -1,4 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { requireAdmin } from "@/lib/auth";
const TIKTOK_OAUTH_STATE_COOKIE = "tiktok-oauth-state";
function escapeHtml(value: unknown) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
/**
* GET /api/auth/tiktok/callback
@@ -7,10 +20,20 @@ import { NextRequest, NextResponse } from "next/server";
* Displays the tokens so you can copy them to your .env file.
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const error = searchParams.get("error");
const cookieStore = await cookies();
const expectedState = cookieStore.get(TIKTOK_OAUTH_STATE_COOKIE)?.value;
const state = searchParams.get("state");
cookieStore.delete(TIKTOK_OAUTH_STATE_COOKIE);
if (!expectedState || !state || state !== expectedState) {
return NextResponse.json({ error: "Invalid OAuth state" }, { status: 400 });
}
if (error) {
return NextResponse.json(
{ error, description: searchParams.get("error_description") },
@@ -52,11 +75,11 @@ export async function GET(request: NextRequest) {
}
);
const tokenData = await tokenRes.json();
const tokenData = (await tokenRes.json()) as Record<string, unknown>;
if (tokenData.error) {
return NextResponse.json(
{ error: tokenData.error, description: tokenData.error_description },
{ error: tokenData.error, description: tokenData.error_description },
{ status: 400 }
);
}
@@ -71,20 +94,20 @@ export async function GET(request: NextRequest) {
<p>Copy these values to your <code>.env</code> file:</p>
<div style="background: #1a1a1a; padding: 16px; border-radius: 8px; margin: 16px 0; word-break: break-all;">
<p><strong>TIKTOK_ACCESS_TOKEN=</strong><br/><code>${tokenData.access_token}</code></p>
<p><strong>TIKTOK_REFRESH_TOKEN=</strong><br/><code>${tokenData.refresh_token}</code></p>
<p><strong>TIKTOK_OPEN_ID=</strong><br/><code>${tokenData.open_id}</code></p>
<p><strong>TIKTOK_ACCESS_TOKEN=</strong><br/><code>${escapeHtml(tokenData.access_token)}</code></p>
<p><strong>TIKTOK_REFRESH_TOKEN=</strong><br/><code>${escapeHtml(tokenData.refresh_token)}</code></p>
<p><strong>TIKTOK_OPEN_ID=</strong><br/><code>${escapeHtml(tokenData.open_id)}</code></p>
</div>
<p style="color: #a3a3a3; font-size: 14px;">
Access token expires in ${Math.floor(tokenData.expires_in / 3600)} hours.<br/>
Refresh token expires in ${Math.floor(tokenData.refresh_expires_in / 86400)} days.<br/>
Access token expires in ${Math.floor(Number(tokenData.expires_in ?? 0) / 3600)} hours.<br/>
Refresh token expires in ${Math.floor(Number(tokenData.refresh_expires_in ?? 0) / 86400)} days.<br/>
The server will auto-refresh the access token using the refresh token.
</p>
<details style="margin-top: 20px;">
<summary style="cursor: pointer;">Full response</summary>
<pre style="background: #1a1a1a; padding: 12px; border-radius: 6px; overflow-x: auto;">${JSON.stringify(tokenData, null, 2)}</pre>
<pre style="background: #1a1a1a; padding: 12px; border-radius: 6px; overflow-x: auto;">${escapeHtml(JSON.stringify(tokenData, null, 2))}</pre>
</details>
</body>
</html>`;
+18 -2
View File
@@ -1,4 +1,9 @@
import { NextResponse } from "next/server";
import { randomBytes } from "node:crypto";
import { cookies } from "next/headers";
import { requireAdmin } from "@/lib/auth";
const TIKTOK_OAUTH_STATE_COOKIE = "tiktok-oauth-state";
/**
* GET /api/auth/tiktok
@@ -6,6 +11,8 @@ import { NextResponse } from "next/server";
* Visit this URL once to start the OAuth flow and get your refresh token.
*/
export async function GET() {
const clientKey = process.env.TIKTOK_CLIENT_KEY;
const redirectUri = `${process.env.BASE_URL}/api/auth/tiktok/callback`;
@@ -16,7 +23,7 @@ export async function GET() {
);
}
const csrfState = Math.random().toString(36).substring(2);
const csrfState = randomBytes(32).toString("hex");
const params = new URLSearchParams({
client_key: clientKey,
@@ -28,5 +35,14 @@ export async function GET() {
const authUrl = `https://www.tiktok.com/v2/auth/authorize/?${params.toString()}`;
return NextResponse.redirect(authUrl);
const response = NextResponse.redirect(authUrl);
const cookieStore = await cookies();
cookieStore.set(TIKTOK_OAUTH_STATE_COOKIE, csrfState, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 600,
path: "/api/auth/tiktok",
});
return response;
}
+3 -2
View File
@@ -20,12 +20,13 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
validateImageUploadFile(file);
const data = Buffer.from(await file.arrayBuffer());
validateImageUploadFile(file, data);
const [record] = await db
.insert(cdn)
.values({
data: Buffer.from(await file.arrayBuffer()),
data,
name: file.name || null,
type: file.type,
size: file.size,
+9 -148
View File
@@ -54,10 +54,17 @@ import {
} from "@/components/ui/field";
import { Spinner } from "@/components/ui/spinner";
import {
decodeCheckboxAnswer,
type FormAnswerValue,
} from "@/lib/form-answer-values";
import { isImageOnlyResponse } from "@/lib/question-response-type";
import {
buildFormSchema,
parseStoredDraft,
prepareInitialState,
type FormFillerClientProps,
type FormValues,
type StoredDraft,
} from "@/lib/form-client";
export function FormLayoutClient({
userName,
@@ -101,153 +108,7 @@ export function LoginButton() {
);
}
interface Question {
id: string;
formId: string;
type: "text" | "textarea" | "radio" | "checkbox";
label: string;
imageUrl: string | null;
displayOrder: number;
required: boolean;
options: string[] | null;
allowOther: boolean;
imageAnswerMode: "none" | "optional" | "required";
}
type AnswerDraft = {
value: FormAnswerValue;
imageCdnId: string | null;
};
interface FormFillerClientProps {
form: {
id: string;
title: string;
description: string | null;
isOpen: boolean;
closesAt: string | null;
allowMultipleSubmissions: boolean;
};
questions: Question[];
initialAnswers?: Record<string, AnswerDraft>;
hasSubmission?: boolean;
selectedSubmissionId?: string | null;
ownSubmissions?: {
id: string;
submittedAt: string;
answers: Record<string, AnswerDraft>;
}[];
totalAnswers: number;
queueNumber: number | null;
preview?: boolean;
}
const LS_KEY = (formId: string) => `form-draft-${formId}`;
type FormValues = Record<string, string | string[] | undefined>;
type StoredDraft = {
version: 1;
answers: FormValues;
otherValues: Record<string, string>;
imageAnswers?: Record<string, string | null>;
};
const buildSchema = (questions: Question[]) => {
const shape: Record<string, z.ZodTypeAny> = {};
questions.forEach(q => {
if (q.type === "checkbox") {
shape[q.id] = q.required
? z.array(z.string()).min(1, `จำเป็นต้องตอบข้อนี้`)
: z.array(z.string()).optional();
} else {
shape[q.id] = q.required
? z.string().trim().min(1, `จำเป็นต้องตอบข้อนี้`)
: z.string().optional();
}
});
return z.object(shape);
};
function prepareInitialState(
questions: Question[],
answers: Record<string, AnswerDraft | undefined>,
savedOtherValues: Record<string, string> = {}
) {
const values: FormValues = {};
const imageAnswers: Record<string, string | null> = {};
const otherValues = { ...savedOtherValues };
questions.forEach((question) => {
const answer = answers[question.id]?.value;
imageAnswers[question.id] = answers[question.id]?.imageCdnId ?? null;
if (question.type === "checkbox") {
let selected = decodeCheckboxAnswer(answer);
if (question.allowOther && question.options) {
const customAnswers = selected.filter(
(value) => !question.options?.includes(value) && value !== "__OTHER__"
);
if (customAnswers.length > 0) {
otherValues[question.id] = customAnswers.join(", ");
}
if (selected.includes("__OTHER__") || customAnswers.length > 0) {
selected = selected.filter((value) => question.options?.includes(value));
selected.push("__OTHER__");
}
}
values[question.id] = selected;
return;
}
if (question.type === "radio") {
let selected = typeof answer === "string" ? answer : "";
if (
question.allowOther &&
question.options &&
selected &&
!question.options.includes(selected)
) {
if (selected !== "__OTHER__") {
otherValues[question.id] = selected;
}
selected = "__OTHER__";
}
values[question.id] = selected;
return;
}
values[question.id] = typeof answer === "string" ? answer : "";
});
return { values, otherValues, imageAnswers };
}
function parseStoredDraft(raw: string): {
answers: FormValues;
otherValues: Record<string, string>;
imageAnswers: Record<string, string | null>;
} {
const parsed: unknown = JSON.parse(raw);
if (
parsed &&
typeof parsed === "object" &&
"version" in parsed &&
parsed.version === 1 &&
"answers" in parsed
) {
const draft = parsed as StoredDraft;
return {
answers: draft.answers ?? {},
otherValues: draft.otherValues ?? {},
imageAnswers: draft.imageAnswers ?? {},
};
}
return {
answers: (parsed as FormValues) ?? {},
otherValues: {},
imageAnswers: {},
};
}
export function FormFillerClient({
form,
@@ -266,7 +127,7 @@ export function FormFillerClient({
);
const isUpdating = Boolean(selectedSubmissionId) || (!form.allowMultipleSubmissions && hasSubmission);
const formSchema = useMemo(() => buildSchema(questions), [questions]);
const formSchema = useMemo(() => buildFormSchema(questions), [questions]);
const serverInitialState = useMemo(
() => prepareInitialState(questions, initialAnswers),
[initialAnswers, questions]
+2 -12
View File
@@ -1,4 +1,4 @@
import { config, config_p2 } from "@/lib/config";
import { config } from "@/lib/config";
import { Card, CardHeader } from "@/components/ui/card";
import Image from "next/image";
import { getFollowerCounts } from "@/lib/followers";
@@ -8,12 +8,6 @@ import { headers } from "next/headers";
import { connection } from "next/server";
import { Users } from "lucide-react";
import { Kbd } from "@/components/ui/kbd";
import { Button } from "@/components/ui/button";
import { ShieldCheck } from "lucide-react";
import Link from "next/link";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { getAdminDiscordIds, getSessionDiscordId } from "@/lib/auth";
import Background from "@/public/background/space.webp";
export default async function Home() {
@@ -46,10 +40,6 @@ export default async function Home() {
// Use configured profile data.
const profileName = config.profile.name;
const profileAvatar = config.profile.avatar;
const session = await getServerSession(authOptions);
const discordId = getSessionDiscordId(session);
const isAdmin = Boolean(discordId && getAdminDiscordIds().includes(discordId));
const links1 = [...config.links];
const jsonLd = {
@@ -107,7 +97,7 @@ export default async function Home() {
</div>
</CardHeader>
<LinkCard counts={counts} links1={links1} links2={config_p2.links} />
<LinkCard counts={counts} links1={links1} links2={config.secondaryLinks} />
</Card>
</main>
);