fix: restricts management file uploads size to be less than 5MB (#6669)

This commit is contained in:
Anshuman Pandey
2025-10-09 10:32:52 +05:30
committed by GitHub
parent 84b3c57087
commit cdf0926c60
17 changed files with 78 additions and 54 deletions

View File

@@ -1,3 +1,6 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TUploadPublicFileRequest, ZUploadPublicFileRequest } from "@formbricks/types/storage";
import { checkAuth } from "@/app/api/v1/management/storage/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
@@ -5,9 +8,6 @@ import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-l
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getSignedUrlForUpload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TUploadPublicFileRequest, ZUploadPublicFileRequest } from "@formbricks/types/storage";
// api endpoint for getting a signed url for uploading a public file
// uploaded files will be public, anyone can access the file
@@ -52,7 +52,16 @@ export const POST = withV1ApiWrapper({
};
}
const signedUrlResponse = await getSignedUrlForUpload(fileName, environmentId, fileType, "public");
const MAX_PUBLIC_FILE_SIZE_MB = 5;
const maxFileUploadSize = MAX_PUBLIC_FILE_SIZE_MB * 1024 * 1024;
const signedUrlResponse = await getSignedUrlForUpload(
fileName,
environmentId,
fileType,
"public",
maxFileUploadSize
);
if (!signedUrlResponse.ok) {
logger.error({ error: signedUrlResponse.error }, "Error getting signed url for upload");

View File

@@ -1,15 +1,15 @@
import {
removeOrganizationEmailLogoUrlAction,
sendTestEmailAction,
updateOrganizationEmailLogoUrlAction,
} from "@/modules/ee/whitelabel/email-customization/actions";
import { handleFileUpload } from "@/modules/storage/file-upload";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import {
removeOrganizationEmailLogoUrlAction,
sendTestEmailAction,
updateOrganizationEmailLogoUrlAction,
} from "@/modules/ee/whitelabel/email-customization/actions";
import { handleFileUpload } from "@/modules/storage/file-upload";
import { EmailCustomizationSettings } from "./email-customization-settings";
vi.mock("@/lib/constants", () => ({
@@ -107,7 +107,6 @@ describe("EmailCustomizationSettings", () => {
const saveButton = screen.getAllByRole("button", { name: /save/i });
await user.click(saveButton[0]);
// The component calls `uploadFile` then `updateOrganizationEmailLogoUrlAction`
expect(handleFileUpload).toHaveBeenCalledWith(testFile, "env-123", ["jpeg", "png", "jpg", "webp"]);
expect(updateOrganizationEmailLogoUrlAction).toHaveBeenCalledWith({
organizationId: "org-123",

View File

@@ -1,5 +1,14 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { RepeatIcon, Trash2Icon } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import React, { useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { TOrganization } from "@formbricks/types/organizations";
import { TAllowedFileExtension } from "@formbricks/types/storage";
import { TUser } from "@formbricks/types/user";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
@@ -15,15 +24,6 @@ import { Uploader } from "@/modules/ui/components/file-input/components/uploader
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
import { Muted, P, Small } from "@/modules/ui/components/typography";
import { ModalButton, UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { useTranslate } from "@tolgee/react";
import { RepeatIcon, Trash2Icon } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import React, { useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { TOrganization } from "@formbricks/types/organizations";
import { TAllowedFileExtension } from "@formbricks/types/storage";
import { TUser } from "@formbricks/types/user";
const allowedFileExtensions: TAllowedFileExtension[] = ["jpeg", "png", "jpg", "webp"];

View File

@@ -1,5 +1,10 @@
"use client";
import { Project } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import Image from "next/image";
import { ChangeEvent, useRef, useState } from "react";
import toast from "react-hot-toast";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateProjectAction } from "@/modules/projects/settings/actions";
import { handleFileUpload } from "@/modules/storage/file-upload";
@@ -11,11 +16,6 @@ import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { FileInput } from "@/modules/ui/components/file-input";
import { Input } from "@/modules/ui/components/input";
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
import { Project } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import Image from "next/image";
import { ChangeEvent, useRef, useState } from "react";
import toast from "react-hot-toast";
interface EditLogoProps {
project: Project;
@@ -151,6 +151,7 @@ export const EditLogo = ({ project, environmentId, isReadOnly, isStorageConfigur
setIsEditing(true);
}}
disabled={isReadOnly}
maxSizeInMB={5}
isStorageConfigured={isStorageConfigured}
/>
)}

View File

@@ -1,7 +1,7 @@
export enum FileUploadError {
NO_FILE = "No file provided or invalid file type. Expected a File or Blob.",
INVALID_FILE_TYPE = "Please upload an image file.",
FILE_SIZE_EXCEEDED = "File size must be less than 10 MB.",
FILE_SIZE_EXCEEDED = "File size must be less than 5 MB.",
UPLOAD_FAILED = "Upload failed. Please try again.",
INVALID_FILE_NAME = "Invalid file name. Please rename your file and try again.",
}
@@ -36,7 +36,9 @@ export const handleFileUpload = async (
const bufferBytes = fileBuffer.byteLength;
const bufferKB = bufferBytes / 1024;
if (bufferKB > 10240) {
const MAX_FILE_SIZE_MB = 5;
const maxSizeInKB = MAX_FILE_SIZE_MB * 1024;
if (bufferKB > maxSizeInKB) {
return {
error: FileUploadError.FILE_SIZE_EXCEEDED,
url: "",

View File

@@ -346,6 +346,7 @@ export const QuestionFormInput = ({
fileUrl={getFileUrl()}
videoUrl={getVideoUrl()}
isVideoAllowed={true}
maxSizeInMB={5}
isStorageConfigured={isStorageConfigured}
/>
)}

View File

@@ -1,6 +1,6 @@
import { UploadImageSurveyBg } from "@/modules/survey/editor/components/image-survey-bg";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { UploadImageSurveyBg } from "@/modules/survey/editor/components/image-survey-bg";
// Create a ref to store the props passed to FileInput
const mockFileInputProps: any = { current: null };
@@ -44,7 +44,7 @@ describe("UploadImageSurveyBg", () => {
allowedFileExtensions: ["png", "jpeg", "jpg", "webp", "heic"],
environmentId: mockEnvironmentId,
fileUrl: mockBackground,
maxSizeInMB: 2,
maxSizeInMB: 5,
});
});
@@ -197,7 +197,7 @@ describe("UploadImageSurveyBg", () => {
expect(mockHandleBgChange).not.toHaveBeenCalled();
});
test("should not call handleBgChange when a file exceeding 2MB size limit is uploaded", () => {
test("should not call handleBgChange when a file exceeding 5MB size limit is uploaded", () => {
render(
<UploadImageSurveyBg
environmentId={mockEnvironmentId}
@@ -209,7 +209,7 @@ describe("UploadImageSurveyBg", () => {
// Verify FileInput was rendered with correct maxSizeInMB prop
expect(screen.getByTestId("file-input-mock")).toBeInTheDocument();
expect(mockFileInputProps.current?.maxSizeInMB).toBe(2);
expect(mockFileInputProps.current?.maxSizeInMB).toBe(5);
// Get the onFileUpload function from the props passed to FileInput
const onFileUpload = mockFileInputProps.current?.onFileUpload;

View File

@@ -28,7 +28,7 @@ export const UploadImageSurveyBg = ({
}
}}
fileUrl={background}
maxSizeInMB={2}
maxSizeInMB={5}
isStorageConfigured={isStorageConfigured}
/>
</div>

View File

@@ -1,12 +1,5 @@
"use client";
import { cn } from "@/lib/cn";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { Button } from "@/modules/ui/components/button";
import { FileInput } from "@/modules/ui/components/file-input";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { createId } from "@paralleldrive/cuid2";
import { useTranslate } from "@tolgee/react";
@@ -14,6 +7,13 @@ import { PlusIcon } from "lucide-react";
import type { JSX } from "react";
import { TSurvey, TSurveyPictureSelectionQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { Button } from "@/modules/ui/components/button";
import { FileInput } from "@/modules/ui/components/file-input";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
interface PictureSelectionFormProps {
localSurvey: TSurvey;
@@ -141,6 +141,7 @@ export const PictureSelectionForm = ({
onFileUpload={handleFileInputChanges}
fileUrl={question?.choices?.map((choice) => choice.imageUrl)}
multiple={true}
maxSizeInMB={5}
isStorageConfigured={isStorageConfigured}
/>
</div>

View File

@@ -1,16 +1,16 @@
"use client";
import { cn } from "@/lib/cn";
import { FileUploadError, handleFileUpload } from "@/modules/storage/file-upload";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
import { useTranslate } from "@tolgee/react";
import { FileIcon, XIcon } from "lucide-react";
import Image from "next/image";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { TAllowedFileExtension } from "@formbricks/types/storage";
import { cn } from "@/lib/cn";
import { FileUploadError, handleFileUpload } from "@/modules/storage/file-upload";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
import { Uploader } from "./components/uploader";
import { VideoSettings } from "./components/video-settings";
import { getAllowedFiles } from "./lib/utils";

View File

@@ -51,7 +51,7 @@ describe("File Input Utils", () => {
expect(result).toHaveLength(1);
expect(result[0].name).toBe("test.txt");
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("Unsupported file types: test.doc"));
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("Unsupported file type."));
});
test("should filter out files exceeding size limit", async () => {
@@ -64,7 +64,7 @@ describe("File Input Utils", () => {
expect(result).toHaveLength(1);
expect(result[0].name).toBe("small.txt");
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("Files exceeding size limit (5 MB)"));
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("File exceeds 5 MB size limit."));
});
test("should convert HEIC files to JPEG", async () => {

View File

@@ -63,10 +63,21 @@ export const getAllowedFiles = async (
let toastMessage = "";
if (sizeExceedFiles.length > 0) {
toastMessage += `Files exceeding size limit (${maxSizeInMB} MB): ${sizeExceedFiles.join(", ")}. `;
if (sizeExceedFiles.length === 1) {
toastMessage += `File exceeds ${maxSizeInMB} MB size limit.`;
} else {
toastMessage += `${sizeExceedFiles.length} files exceed ${maxSizeInMB} MB size limit.`;
}
}
if (unsupportedExtensionFiles.length > 0) {
toastMessage += `Unsupported file types: ${unsupportedExtensionFiles.join(", ")}.`;
if (toastMessage) {
toastMessage += " ";
}
if (unsupportedExtensionFiles.length === 1) {
toastMessage += `Unsupported file type.`;
} else {
toastMessage += `${unsupportedExtensionFiles.length} files have unsupported types.`;
}
}
if (toastMessage) {
toast.error(toastMessage);

View File

@@ -5675,7 +5675,7 @@
},
"/api/v1/management/storage": {
"post": {
"description": "API endpoint for uploading public files. Uploaded files are public and accessible by anyone. This endpoint requires authentication. It accepts a JSON body with fileName, fileType, environmentId, and optionally allowedFileExtensions to restrict file types. On success, it returns a signed URL for uploading the file to S3.",
"description": "API endpoint for uploading public files. Uploaded files are public and accessible by anyone. This endpoint requires authentication and enforces a hard limit of 5 MB for all uploads. It accepts a JSON body with fileName, fileType, environmentId, and optionally allowedFileExtensions to restrict file types. On success, it returns a signed URL for uploading the file to S3.",
"parameters": [
{
"example": "{{apiKey}}",

View File

@@ -22,7 +22,7 @@ Email branding is a white-label feature that allows you to customize the email t
![Email Customization Settings](/images/xm-and-surveys/core-features/email-customization/email-customization-card.webp)
3. Upload a logo of your company.
3. Upload a logo of your company. Logos must be 5 MB or less.
4. Click on the **Save** button.
![Updated Logo](/images/xm-and-surveys/core-features/email-customization/updated-logo.webp)

View File

@@ -49,7 +49,7 @@ In the left side bar, you find the `Configuration` page. On this page you find t
- **Color**: Pick any color for the background
- **Animation**: Add dynamic animations to enhance user experience..
- **Upload**: Use a custom uploaded image for a personalized touch
- **Upload**: Use a custom uploaded image for a personalized touch. Images must be 5 MB or less.
- Image: Choose from Unsplash's extensive gallery. Note that these images will have a link and mention of the author & Unsplash on the bottom right to give them the credit for their awesome work!
- **Background Overlay**: Adjust the background's opacity
@@ -63,7 +63,7 @@ Customize your survey with your brand's logo.
![Choose a link survey template](/images/xm-and-surveys/core-features/styling-theme/step-four.webp)
2. Upload your logo
2. Upload your logo. Logos must be 5 MB or less.
![Choose a link survey template](/images/xm-and-surveys/core-features/styling-theme/step-five.webp)

View File

@@ -16,7 +16,7 @@ Click the icon on the right side of the question to add an image or video:
![Access Question settings](/images/xm-and-surveys/surveys/general-features/add-image-or-video-question/add-image-or-video-to-question.webp)
Upload an image by clicking the upload icon or dragging the file:
Upload an image by clicking the upload icon or dragging the file. Images must be 5 MB or less:
![Overview of adding image to question](/images/xm-and-surveys/surveys/general-features/add-image-or-video-question/add-image-or-video-to-question-image.webp)

View File

@@ -40,7 +40,7 @@ Provide an optional description with further instructions.
### Images
Images can be uploaded via click or drag and drop. At least two images are required.
Images can be uploaded via click or drag and drop. At least two images are required. Each image must be 5 MB or less.
### Allow Multi Select