fix: moves storage api management endpoint to use payload instead of … (#5348)

Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
This commit is contained in:
Anshuman Pandey
2025-05-07 10:25:58 +05:30
committed by GitHub
parent 16479eb6cf
commit 6441c0aa31
24 changed files with 566 additions and 233 deletions
@@ -1,5 +1,6 @@
"use client";
import { handleFileUpload } from "@/app/lib/fileUpload";
import { cn } from "@/lib/cn";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
@@ -11,7 +12,7 @@ import toast from "react-hot-toast";
import { TAllowedFileExtension } from "@formbricks/types/common";
import { Uploader } from "./components/uploader";
import { VideoSettings } from "./components/video-settings";
import { getAllowedFiles, uploadFile } from "./lib/utils";
import { getAllowedFiles } from "./lib/utils";
const allowedFileTypesForPreview = ["png", "jpeg", "jpg", "webp"];
const isImage = (name: string) => {
@@ -21,7 +22,7 @@ const isImage = (name: string) => {
interface FileInputProps {
id: string;
allowedFileExtensions: TAllowedFileExtension[];
environmentId: string | undefined;
environmentId: string;
onFileUpload: (uploadedUrl: string[] | undefined, fileType: "image" | "video") => void;
fileUrl?: string | string[];
videoUrl?: string;
@@ -78,14 +79,11 @@ export const FileInput = ({
allowedFiles.map((file) => ({ url: URL.createObjectURL(file), name: file.name, uploaded: false }))
);
const uploadedFiles = await Promise.allSettled(
allowedFiles.map((file) => uploadFile(file, allowedFileExtensions, environmentId))
const uploadedFiles = await Promise.all(
allowedFiles.map((file) => handleFileUpload(file, environmentId, allowedFileExtensions))
);
if (
uploadedFiles.length < allowedFiles.length ||
uploadedFiles.some((file) => file.status === "rejected")
) {
if (uploadedFiles.length < allowedFiles.length || uploadedFiles.some((file) => file.error)) {
if (uploadedFiles.length === 0) {
toast.error(t("common.no_files_uploaded"));
} else {
@@ -95,8 +93,8 @@ export const FileInput = ({
const uploadedUrls: string[] = [];
uploadedFiles.forEach((file) => {
if (file.status === "fulfilled") {
uploadedUrls.push(encodeURI(file.value.url));
if (file.url) {
uploadedUrls.push(encodeURI(file.url));
}
});
@@ -147,14 +145,11 @@ export const FileInput = ({
...allowedFiles.map((file) => ({ url: URL.createObjectURL(file), name: file.name, uploaded: false })),
]);
const uploadedFiles = await Promise.allSettled(
allowedFiles.map((file) => uploadFile(file, allowedFileExtensions, environmentId))
const uploadedFiles = await Promise.all(
allowedFiles.map((file) => handleFileUpload(file, environmentId, allowedFileExtensions))
);
if (
uploadedFiles.length < allowedFiles.length ||
uploadedFiles.some((file) => file.status === "rejected")
) {
if (uploadedFiles.length < allowedFiles.length || uploadedFiles.some((file) => file.error)) {
if (uploadedFiles.length === 0) {
toast.error(t("common.no_files_uploaded"));
} else {
@@ -164,8 +159,8 @@ export const FileInput = ({
const uploadedUrls: string[] = [];
uploadedFiles.forEach((file) => {
if (file.status === "fulfilled") {
uploadedUrls.push(encodeURI(file.value.url));
if (file.url) {
uploadedUrls.push(encodeURI(file.url));
}
});
@@ -0,0 +1,101 @@
import { toast } from "react-hot-toast";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { convertHeicToJpegAction } from "./actions";
import { checkForYoutubePrivacyMode, getAllowedFiles } from "./utils";
// Mock FileReader
class MockFileReader {
onload: (() => void) | null = null;
onerror: ((error: any) => void) | null = null;
result: string | null = null;
readAsDataURL() {
// Simulate asynchronous read
setTimeout(() => {
this.result = "data:text/plain;base64,dGVzdA=="; // base64 for "test"
if (this.onload) {
this.onload();
}
}, 0);
}
}
// Mock global FileReader
global.FileReader = MockFileReader as any;
// Mock dependencies
vi.mock("react-hot-toast", () => ({
toast: {
error: vi.fn(),
},
}));
vi.mock("./actions", () => ({
convertHeicToJpegAction: vi.fn(),
}));
describe("File Input Utils", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getAllowedFiles", () => {
test("should filter out files with unsupported extensions", async () => {
const files = [
new File(["test"], "test.txt", { type: "text/plain" }),
new File(["test"], "test.doc", { type: "application/msword" }),
];
const result = await getAllowedFiles(files, ["txt"], 5);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("test.txt");
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("Unsupported file types: test.doc"));
});
test("should filter out files exceeding size limit", async () => {
const files = [
new File(["x".repeat(6 * 1024 * 1024)], "large.txt", { type: "text/plain" }),
new File(["test"], "small.txt", { type: "text/plain" }),
];
const result = await getAllowedFiles(files, ["txt"], 5);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("small.txt");
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("Files exceeding size limit (5 MB)"));
});
test("should convert HEIC files to JPEG", async () => {
const heicFile = new File(["test"], "test.heic", { type: "image/heic" });
const mockConvertedFile = new File(["converted"], "test.jpg", { type: "image/jpeg" });
vi.mocked(convertHeicToJpegAction).mockResolvedValue({
data: mockConvertedFile,
});
const result = await getAllowedFiles([heicFile], ["heic", "jpg"], 5);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("test.jpg");
expect(result[0].type).toBe("image/jpeg");
});
});
describe("checkForYoutubePrivacyMode", () => {
test("should return true for youtube-nocookie.com URLs", () => {
const url = "https://www.youtube-nocookie.com/watch?v=test";
expect(checkForYoutubePrivacyMode(url)).toBe(true);
});
test("should return false for regular youtube.com URLs", () => {
const url = "https://www.youtube.com/watch?v=test";
expect(checkForYoutubePrivacyMode(url)).toBe(false);
});
test("should return false for invalid URLs", () => {
const url = "not-a-url";
expect(checkForYoutubePrivacyMode(url)).toBe(false);
});
});
});
@@ -4,96 +4,6 @@ import { toast } from "react-hot-toast";
import { TAllowedFileExtension } from "@formbricks/types/common";
import { convertHeicToJpegAction } from "./actions";
export const uploadFile = async (
file: File | Blob,
allowedFileExtensions: string[] | undefined,
environmentId: string | undefined
) => {
try {
if (!(file instanceof Blob) || !(file instanceof File)) {
throw new Error(`Invalid file type. Expected Blob or File, but received ${typeof file}`);
}
const fileBuffer = await file.arrayBuffer();
const bufferBytes = fileBuffer.byteLength;
const bufferKB = bufferBytes / 1024;
if (bufferKB > 10240) {
const err = new Error("File size is greater than 10MB");
err.name = "FileTooLargeError";
throw err;
}
const payload = {
fileName: file.name,
fileType: file.type,
allowedFileExtensions: allowedFileExtensions,
environmentId: environmentId,
};
const response = await fetch("/api/v1/management/storage", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Upload failed with status: ${response.status}`);
}
const json = await response.json();
const { data } = json;
const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data;
let requestHeaders: Record<string, string> = {};
if (signingData) {
const { signature, timestamp, uuid } = signingData;
requestHeaders = {
"X-File-Type": file.type,
"X-File-Name": encodeURIComponent(updatedFileName),
"X-Environment-ID": environmentId ?? "",
"X-Signature": signature,
"X-Timestamp": String(timestamp),
"X-UUID": uuid,
};
}
const formData = new FormData();
if (presignedFields) {
Object.keys(presignedFields).forEach((key) => {
formData.append(key, presignedFields[key]);
});
}
formData.append("file", file);
const uploadResponse = await fetch(signedUrl, {
method: "POST",
...(signingData ? { headers: requestHeaders } : {}),
body: formData,
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed with status: ${uploadResponse.status}`);
}
return {
uploaded: true,
url: fileUrl,
};
} catch (error) {
throw error;
}
};
const isFileSizeExceed = (fileSizeInMB: number, maxSizeInMB?: number) => {
if (maxSizeInMB && fileSizeInMB > maxSizeInMB) {
return true;
@@ -169,6 +79,7 @@ export const checkForYoutubePrivacyMode = (url: string): boolean => {
const parsedUrl = new URL(url);
return parsedUrl.host === "www.youtube-nocookie.com";
} catch (e) {
console.error("Invalid URL", e);
return false;
}
};