From 96c52e8ad6d6e6d212287b1af17af5dfeb74d72c Mon Sep 17 00:00:00 2001 From: Gunshiz Date: Sat, 30 May 2026 01:06:52 +0700 Subject: [PATCH] refactor: replace server action with API route for image uploads and update Nginx rate limiting configuration --- app/actions/upload.ts | 42 -------------------------------- app/api/upload/route.ts | 51 +++++++++++++++++++++++++++++++++++++++ app/form/admin/client.tsx | 15 ++++++++++-- nginx.conf | 14 +++++++++-- 4 files changed, 76 insertions(+), 46 deletions(-) delete mode 100644 app/actions/upload.ts create mode 100644 app/api/upload/route.ts diff --git a/app/actions/upload.ts b/app/actions/upload.ts deleted file mode 100644 index 1eb95b1..0000000 --- a/app/actions/upload.ts +++ /dev/null @@ -1,42 +0,0 @@ -"use server"; - -import { writeFile, mkdir } from "fs/promises"; -import path from "path"; -import { getServerSession } from "next-auth"; -import { authOptions } from "@/app/api/auth/[...nextauth]/route"; - -export async function uploadImage(formData: FormData): Promise { - 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"); - } - - const file = formData.get("file") as File; - if (!file) throw new Error("No file provided"); - - if (!file.type.startsWith("image/")) { - throw new Error("Only image files are allowed"); - } - - const allowedExtensions = ["png", "jpg", "jpeg", "webp", "gif"]; - const originalExt = (file.name.split(".").pop() || "").toLowerCase(); - - if (!allowedExtensions.includes(originalExt)) { - throw new Error("Invalid image extension"); - } - - const filename = `${crypto.randomUUID()}.${originalExt}`; - const uploadDir = path.join(process.cwd(), "public", "form"); - - await mkdir(uploadDir, { recursive: true }); - const buffer = Buffer.from(await file.arrayBuffer()); - await writeFile(path.join(uploadDir, filename), buffer); - - return `/form/${filename}`; -} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..9ea768c --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { writeFile, mkdir } from "fs/promises"; +import path from "path"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +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)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await req.formData(); + const file = formData.get("file") as File | null; + + if (!file) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }); + } + + if (!file.type.startsWith("image/")) { + return NextResponse.json({ error: "Only image files are allowed" }, { status: 400 }); + } + + const allowedExtensions = ["png", "jpg", "jpeg", "webp", "gif"]; + const originalExt = (file.name.split(".").pop() || "").toLowerCase(); + + if (!allowedExtensions.includes(originalExt)) { + return NextResponse.json({ error: "Invalid image extension" }, { status: 400 }); + } + + const filename = `${crypto.randomUUID()}.${originalExt}`; + const uploadDir = path.join(process.cwd(), "public", "form"); + + await mkdir(uploadDir, { recursive: true }); + const buffer = Buffer.from(await file.arrayBuffer()); + await writeFile(path.join(uploadDir, filename), buffer); + + return NextResponse.json({ url: `/form/${filename}` }); + } catch (error: any) { + console.error("Upload error:", error); + return NextResponse.json({ error: error.message || "Internal server error" }, { status: 500 }); + } +} diff --git a/app/form/admin/client.tsx b/app/form/admin/client.tsx index a959d87..2f55da2 100644 --- a/app/form/admin/client.tsx +++ b/app/form/admin/client.tsx @@ -18,7 +18,6 @@ import { deleteQuestion, reorderQuestions, } from "@/app/actions/questions"; -import { uploadImage } from "@/app/actions/upload"; import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Save, Image as ImageIcon, X } from "lucide-react"; import Image from 'next/image'; @@ -146,7 +145,19 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC setUploadingId(questionId); toast.promise( - uploadImage(formData).then(async (imageUrl) => { + new Promise(async (resolve, reject) => { + try { + const res = await fetch("/api/upload", { + method: "POST", + body: formData, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to upload image"); + resolve(data.url); + } catch (error) { + reject(error); + } + }).then(async (imageUrl) => { await updateQuestion(questionId, form.id, { imageUrl }); setQuestions((prev) => prev.map((q) => (q.id === questionId ? { ...q, imageUrl } : q)) diff --git a/nginx.conf b/nginx.conf index 4af4cf9..4aaa828 100644 --- a/nginx.conf +++ b/nginx.conf @@ -15,9 +15,19 @@ http { server { listen 80; + # Bypass rate limit for static assets and Next.js internal files + location /_next/ { + proxy_pass http://erika:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } + + # Apply rate limit to actual page loads and API requests location / { - # Apply rate limit: allow burst of up to 10 requests without delay - limit_req zone=ratelimit burst=10 nodelay; + # Increased burst to 30 to allow normal page loads that fetch multiple APIs/images + limit_req zone=ratelimit burst=30 nodelay; # Proxy to the Next.js app proxy_pass http://erika:3000;