refactor: update Docker base images, enhance image upload security, and implement shared form client logic and validation
This commit is contained in:
+3
-3
@@ -1,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Stage 1: Install dependencies
|
||||
FROM oven/bun:alpine AS deps
|
||||
FROM oven/bun:1.3.14-alpine AS deps
|
||||
WORKDIR /app
|
||||
# Copy only package files to leverage Docker layer caching
|
||||
COPY package.json bun.lock ./
|
||||
@@ -10,7 +10,7 @@ RUN --mount=type=cache,target=/root/.bun/install/cache \
|
||||
bun install --frozen-lockfile
|
||||
|
||||
# Stage 2: Build the application
|
||||
FROM oven/bun:alpine AS builder
|
||||
FROM oven/bun:1.3.14-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/app/.next/cache \
|
||||
bun run build
|
||||
|
||||
# Stage 3: Production runner
|
||||
FROM oven/bun:alpine AS runner
|
||||
FROM oven/bun:1.3.14-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
@@ -1,62 +1,81 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# Erika
|
||||
|
||||
## Getting Started
|
||||
Erika is a Next.js application for a creator landing page, Discord-authenticated forms, admin form management, submission results, Discord notifications, and XP/voice leaderboards.
|
||||
|
||||
First, run the development server:
|
||||
## Requirements
|
||||
|
||||
- Bun 1.3+
|
||||
- PostgreSQL
|
||||
- Redis for visitor counts and server-sent events
|
||||
- Discord OAuth and bot credentials for authentication and role-based access
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
The development server runs on port `4000`.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
Useful commands:
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
```bash
|
||||
bun run lint # ESLint
|
||||
bunx tsc --noEmit # Type checking
|
||||
bun test # Unit and domain tests
|
||||
bun run db:generate # Generate a Drizzle migration
|
||||
bun run db:migrate # Apply migrations
|
||||
```
|
||||
|
||||
## Learn More
|
||||
## Environment
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
Create `.env.local` for local development. The application uses these groups of variables:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
- `DATABASE_URL` — primary PostgreSQL connection
|
||||
- `LEADERBOARD_DATABASE_URL` — optional read-only leaderboard database
|
||||
- `REDIS_URL` — Redis connection for counters and SSE
|
||||
- `NEXTAUTH_URL`, `NEXTAUTH_SECRET` — authentication configuration
|
||||
- `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` — Discord OAuth
|
||||
- `DISCORD_BOT_TOKEN`, `DISCORD_GUILD_ID` — Discord role and profile lookups
|
||||
- `ADMIN_DISCORD_IDS` — comma-separated Discord IDs allowed into admin tools
|
||||
- `BASE_URL` — canonical public URL used in links and OAuth callbacks
|
||||
- platform credentials used by follower-count integrations
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
Never commit `.env` or `.env.local`, and never expose credentials through `NEXT_PUBLIC_` variables. Before deployment, run:
|
||||
|
||||
## Deploy on Vercel
|
||||
```bash
|
||||
bun run secrets:scan
|
||||
```
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
## Main areas
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
- `/` — public profile and links
|
||||
- `/form` — public form listing and submission
|
||||
- `/admin` — protected administration dashboard
|
||||
- `/leaderboard/xp` — XP leaderboard
|
||||
- `/leaderboard/vc` — voice activity leaderboard
|
||||
- `/sse/[topic]` — authenticated realtime updates
|
||||
- `/api/upload` — authenticated image uploads stored in PostgreSQL
|
||||
|
||||
## Secret safety
|
||||
Forms use server actions for authorization, persistence, validation, Discord webhooks, and cache invalidation. Public form drafts are stored locally in the browser.
|
||||
|
||||
- Keep credentials in `.env` or `.env.local`. Never prefix a credential with
|
||||
`NEXT_PUBLIC_`.
|
||||
- Restrict local access before starting the app:
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
chmod 600 .env .env.local
|
||||
```
|
||||
```bash
|
||||
bun run up
|
||||
bun run logs
|
||||
bun run down
|
||||
```
|
||||
|
||||
- Scan tracked files and Git history before deployment:
|
||||
The application listens on port `3000` inside the container. nginx shares the application network namespace and provides the public port configured in `docker-compose.yml`, including SSE proxy settings.
|
||||
|
||||
```bash
|
||||
bun run secrets:scan
|
||||
```
|
||||
The production image is built in separate dependency, build, and runner stages. Runtime environment variables are injected by Compose rather than copied into the image.
|
||||
|
||||
- Deploy with Docker Compose so `.env` is injected only when the container
|
||||
starts. Environment files are excluded from the Docker build context.
|
||||
- Give the Discord bot only the permissions it needs. Avoid the Administrator
|
||||
permission.
|
||||
- Rotate every credential from `.env` if an environment file, build context,
|
||||
builder cache, log, or host may have been shared. If prior exposure cannot be
|
||||
ruled out, treat the credentials as exposed.
|
||||
## Security notes
|
||||
|
||||
# erika
|
||||
- Admin server actions require the authenticated Discord ID to be in `ADMIN_DISCORD_IDS`.
|
||||
- Form access checks allowed and denied Discord roles.
|
||||
- Uploaded image contents are checked against their declared image type before storage.
|
||||
- TikTok OAuth is a private setup utility and requires an admin session plus verified OAuth state.
|
||||
- Rotate credentials if an environment file, build cache, logs, or deployment host may have been exposed.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { AlertCircle, Check, GripVertical, Loader2 } from "lucide-react";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { AutoSaveStatus } from "@/hooks/use-debounced-autosave";
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { validateImageUploadFile } from "./cdn-images";
|
||||
|
||||
const png = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
|
||||
describe("image upload validation", () => {
|
||||
test("accepts a valid PNG signature", () => {
|
||||
expect(() =>
|
||||
validateImageUploadFile(
|
||||
{ name: "avatar.png", type: "image/png", size: png.length },
|
||||
png
|
||||
)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test("rejects content that does not match the declared type", () => {
|
||||
expect(() =>
|
||||
validateImageUploadFile(
|
||||
{ name: "avatar.png", type: "image/png", size: 4 },
|
||||
new Uint8Array([1, 2, 3, 4])
|
||||
)
|
||||
).toThrow("contents do not match");
|
||||
});
|
||||
|
||||
test("rejects empty files", () => {
|
||||
expect(() =>
|
||||
validateImageUploadFile({ name: "avatar.png", type: "image/png", size: 0 })
|
||||
).toThrow("cannot be empty");
|
||||
});
|
||||
});
|
||||
+37
-1
@@ -9,7 +9,10 @@ const ALLOWED_IMAGE_MIME_TYPES = new Set([
|
||||
|
||||
const ALLOWED_IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "gif"]);
|
||||
|
||||
export function validateImageUploadFile(file: Pick<File, "name" | "type" | "size">) {
|
||||
export function validateImageUploadFile(
|
||||
file: Pick<File, "name" | "type" | "size">,
|
||||
bytes?: Uint8Array
|
||||
) {
|
||||
if (!ALLOWED_IMAGE_MIME_TYPES.has(file.type)) {
|
||||
throw new Error("Only PNG, JPG, WebP, and GIF images are allowed");
|
||||
}
|
||||
@@ -18,10 +21,43 @@ export function validateImageUploadFile(file: Pick<File, "name" | "type" | "size
|
||||
throw new Error("Image must be 50MB or smaller");
|
||||
}
|
||||
|
||||
if (file.size === 0) {
|
||||
throw new Error("Image cannot be empty");
|
||||
}
|
||||
|
||||
const extension = (file.name.split(".").pop() || "").toLowerCase();
|
||||
if (!ALLOWED_IMAGE_EXTENSIONS.has(extension)) {
|
||||
throw new Error("Invalid image extension");
|
||||
}
|
||||
|
||||
if (bytes && !hasMatchingImageSignature(file.type, bytes)) {
|
||||
throw new Error("Image contents do not match the declared image type");
|
||||
}
|
||||
}
|
||||
|
||||
function hasMatchingImageSignature(type: string, bytes: Uint8Array) {
|
||||
if (type === "image/png") {
|
||||
return bytes.length >= 8 &&
|
||||
bytes.slice(0, 8).every((value, index) => value === [137, 80, 78, 71, 13, 10, 26, 10][index]);
|
||||
}
|
||||
|
||||
if (type === "image/jpeg") {
|
||||
return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
||||
}
|
||||
|
||||
if (type === "image/gif") {
|
||||
return bytes.length >= 6 &&
|
||||
(String.fromCharCode(...bytes.slice(0, 6)) === "GIF87a" ||
|
||||
String.fromCharCode(...bytes.slice(0, 6)) === "GIF89a");
|
||||
}
|
||||
|
||||
if (type === "image/webp") {
|
||||
return bytes.length >= 12 &&
|
||||
String.fromCharCode(...bytes.slice(0, 4)) === "RIFF" &&
|
||||
String.fromCharCode(...bytes.slice(8, 12)) === "WEBP";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function cdnUrl(id: string | null | undefined) {
|
||||
|
||||
+3
-7
@@ -50,12 +50,8 @@ export const config = {
|
||||
desc: "กดติดตามใน Youtube",
|
||||
icon: "youtube",
|
||||
}
|
||||
] as LinkConfig[]
|
||||
};
|
||||
|
||||
export const config_p2 = {
|
||||
profile: config.profile,
|
||||
links: [
|
||||
] as LinkConfig[],
|
||||
secondaryLinks: [
|
||||
{
|
||||
name: "Form",
|
||||
link: "/form",
|
||||
@@ -68,5 +64,5 @@ export const config_p2 = {
|
||||
desc: "อันดับ XP และเวลาในห้องเสียง",
|
||||
icon: "leaderboard",
|
||||
}
|
||||
] as LinkConfig[]
|
||||
] as LinkConfig[],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
buildFormSchema,
|
||||
parseStoredDraft,
|
||||
prepareInitialState,
|
||||
type Question,
|
||||
} from "./form-client";
|
||||
|
||||
const questions: Question[] = [
|
||||
{
|
||||
id: "choice",
|
||||
formId: "form",
|
||||
type: "radio",
|
||||
label: "Choice",
|
||||
imageUrl: null,
|
||||
displayOrder: 0,
|
||||
required: true,
|
||||
options: ["A", "B"],
|
||||
allowOther: true,
|
||||
imageAnswerMode: "none",
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
formId: "form",
|
||||
type: "checkbox",
|
||||
label: "Tags",
|
||||
imageUrl: null,
|
||||
displayOrder: 1,
|
||||
required: false,
|
||||
options: ["A", "B"],
|
||||
allowOther: true,
|
||||
imageAnswerMode: "none",
|
||||
},
|
||||
];
|
||||
|
||||
describe("form client domain helpers", () => {
|
||||
test("maps stored custom answers to the Other control", () => {
|
||||
const state = prepareInitialState(questions, {
|
||||
choice: { value: "Custom", imageCdnId: null },
|
||||
tags: { value: JSON.stringify(["A", "Custom tag"]), imageCdnId: null },
|
||||
});
|
||||
|
||||
expect(state.values.choice).toBe("__OTHER__");
|
||||
expect(state.values.tags).toEqual(["A", "__OTHER__"]);
|
||||
expect(state.otherValues).toEqual({ choice: "Custom", tags: "Custom tag" });
|
||||
});
|
||||
|
||||
test("enforces required answers", () => {
|
||||
const schema = buildFormSchema(questions);
|
||||
expect(schema.safeParse({ choice: "", tags: [] }).success).toBe(false);
|
||||
expect(schema.safeParse({ choice: "A", tags: [] }).success).toBe(true);
|
||||
});
|
||||
|
||||
test("parses versioned drafts and rejects malformed JSON", () => {
|
||||
expect(
|
||||
parseStoredDraft(
|
||||
JSON.stringify({ version: 1, answers: { choice: "A" }, otherValues: {} })
|
||||
).answers.choice
|
||||
).toBe("A");
|
||||
expect(() => parseStoredDraft("not-json")).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { z } from "zod";
|
||||
import type { FormAnswerValue } from "@/lib/form-answer-values";
|
||||
import { decodeCheckboxAnswer } from "@/lib/form-answer-values";
|
||||
|
||||
export type 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";
|
||||
};
|
||||
|
||||
export type AnswerDraft = {
|
||||
value: FormAnswerValue;
|
||||
imageCdnId: string | null;
|
||||
};
|
||||
|
||||
export type 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;
|
||||
};
|
||||
|
||||
export type FormValues = Record<string, string | string[] | undefined>;
|
||||
|
||||
export type StoredDraft = {
|
||||
version: 1;
|
||||
answers: FormValues;
|
||||
otherValues: Record<string, string>;
|
||||
imageAnswers?: Record<string, string | null>;
|
||||
};
|
||||
|
||||
export function buildFormSchema(questions: Question[]) {
|
||||
const shape: Record<string, z.ZodTypeAny> = {};
|
||||
questions.forEach((question) => {
|
||||
if (question.type === "checkbox") {
|
||||
shape[question.id] = question.required
|
||||
? z.array(z.string()).min(1, "จำเป็นต้องตอบข้อนี้")
|
||||
: z.array(z.string()).optional();
|
||||
} else {
|
||||
shape[question.id] = question.required
|
||||
? z.string().trim().min(1, "จำเป็นต้องตอบข้อนี้")
|
||||
: z.string().optional();
|
||||
}
|
||||
});
|
||||
return z.object(shape);
|
||||
}
|
||||
|
||||
export 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 };
|
||||
}
|
||||
|
||||
export function parseStoredDraft(raw: string) {
|
||||
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: {},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user