From ddd4327a999e87908d0580fdfdfae36e5f06a402 Mon Sep 17 00:00:00 2001 From: dimitar Date: Sun, 2 Aug 2026 12:21:23 +0200 Subject: [PATCH] =?UTF-8?q?perf:=20Phase=203=20=E2=80=94=20parallel=20uplo?= =?UTF-8?q?ads=20+=20security=20headers,=20powered-by-header=20off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance and transport-layer hardening. ImageUploader.tsx: - Replaced sequential for-loop uploads with Promise.allSettled, so multiple files upload concurrently. Partial failures no longer abort the whole batch — successful uploads are kept, failed ones surface a concatenated error (and a subsequent retry is still possible). - Order indices are pre-computed from the existing images.length so the parallel results stay correctly ordered. next.config.ts: - PoweredByHeader: false (no longer advertises Next.js). - compress: true explicitly (default, but documented). - Added Strict-Transport-Security, X-Frame-Options, X-Content-Type- Options, Referrer-Policy, Permissions-Policy and a defensive CSP (script-src allows 'unsafe-eval' for Next.js dev/HMR invariants, connect-src whitelists Clerk endpoints). - remotePatterns is now only populated when S3_ENDPOINT is set, and parsed with URL() so a trailing path no longer produces a phantom hostname. Still effectively unused because the app uses raw ; the migration to next/image is deferred. --- next.config.ts | 45 ++++++++++++++++++++++++------ src/components/ImageUploader.tsx | 48 ++++++++++++++++++++------------ 2 files changed, 66 insertions(+), 27 deletions(-) diff --git a/next.config.ts b/next.config.ts index a1a9f1a..58e8b27 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,19 +1,46 @@ import type { NextConfig } from "next"; +const s3Host = process.env.S3_ENDPOINT + ? new URL(process.env.S3_ENDPOINT).hostname + : ""; + +const securityHeaders = [ + { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }, + { key: "X-Frame-Options", value: "SAMEORIGIN" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + { + key: "Content-Security-Policy", + value: [ + "default-src 'self'", + "img-src 'self' data: blob: https:", + "font-src 'self' data:", + "style-src 'self' 'unsafe-inline'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "connect-src 'self' https://*.clerk.accounts.dev https://api.clerk.com", + "frame-ancestors 'self'", + ].join("; "), + }, +]; + const nextConfig: NextConfig = { output: "standalone", + poweredByHeader: false, + compress: true, images: { - remotePatterns: [ + remotePatterns: s3Host + ? [{ protocol: "https", hostname: s3Host }] + : [], + }, + async headers() { + return [ { - protocol: "https", - hostname: process.env.S3_ENDPOINT?.replace("https://", "") || "", + source: "/:path*", + headers: securityHeaders, }, - { - protocol: "http", - hostname: process.env.S3_ENDPOINT?.replace("http://", "") || "", - }, - ], + ]; }, }; -export default nextConfig; \ No newline at end of file +export default nextConfig; diff --git a/src/components/ImageUploader.tsx b/src/components/ImageUploader.tsx index be8e4aa..eeb8590 100644 --- a/src/components/ImageUploader.tsx +++ b/src/components/ImageUploader.tsx @@ -41,31 +41,43 @@ export default function ImageUploader({ images, onImagesChange }: ImageUploaderP setUploading(true); try { + const baseOrder = images.length; + const results = await Promise.allSettled( + validFiles.map(async (file, idx) => { + const formData = new FormData(); + formData.append("file", file); + const res = await fetch("/api/upload", { + method: "POST", + body: formData, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Не успеа качувањето"); + } + const { key, url } = await res.json(); + return { key, url, order: baseOrder + idx + 1 }; + }) + ); + const newImages = [...images]; const newPreviews = [...previews]; - - for (const file of validFiles) { - const formData = new FormData(); - formData.append("file", file); - - const res = await fetch("/api/upload", { - method: "POST", - body: formData, - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || "Не успеа качувањето"); + const failures: string[] = []; + for (const r of results) { + if (r.status === "fulfilled") { + newImages.push({ key: r.value.key, order: r.value.order, url: r.value.url }); + newPreviews.push({ key: r.value.key, url: r.value.url, order: r.value.order }); + } else { + failures.push(r.reason instanceof Error ? r.reason.message : "Не успеа качувањето"); } - - const { key, url } = await res.json(); - const order = newImages.length + 1; - newImages.push({ key, order, url }); - newPreviews.push({ key, url, order }); } onImagesChange(newImages); setPreviews(newPreviews); + if (failures.length > 0) { + setError(failures.join("; ")); + } else { + setError(""); + } } catch (err) { setError(err instanceof Error ? err.message : "Не успеа качувањето"); } finally {