Compare commits

...

1 Commits

Author SHA1 Message Date
Bhagya Amarasinghe 7ba9d25180 fix: improve file upload storage errors 2026-05-11 21:53:13 +05:30
50 changed files with 369 additions and 54 deletions
+3 -2
View File
@@ -217,7 +217,8 @@ const successResponse = (data: Object, cors: boolean = false, cache: string = "p
const internalServerErrorResponse = (
message: string,
cors: boolean = false,
cache: string = "private, no-store"
cache: string = "private, no-store",
details: ApiErrorResponse["details"] = {}
) => {
const headers = {
...(cors && corsHeaders),
@@ -228,7 +229,7 @@ const internalServerErrorResponse = (
{
code: "internal_server_error",
message,
details: {},
details,
} as ApiErrorResponse,
{
status: 500,
+3
View File
@@ -241,6 +241,9 @@
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
"failed_to_load_workspaces": "Projekte konnten nicht geladen werden",
"field_placeholder": "{{field}}-Platzhalter",
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
"file_storage_not_set_up": "File storage not set up",
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filter",
"finish": "Fertigstellen",
"first_name": "Vorname",
+3
View File
@@ -241,6 +241,9 @@
"failed_to_load_organizations": "Failed to load organizations",
"failed_to_load_workspaces": "Failed to load workspaces",
"field_placeholder": "{{field}} Placeholder",
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
"file_storage_not_set_up": "File storage not set up",
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filter",
"finish": "Finish",
"first_name": "First Name",
+3
View File
@@ -241,6 +241,9 @@
"failed_to_load_organizations": "Error al cargar organizaciones",
"failed_to_load_workspaces": "Error al cargar los proyectos",
"field_placeholder": "Marcador de posición de {{field}}",
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
"file_storage_not_set_up": "File storage not set up",
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filtro",
"finish": "Finalizar",
"first_name": "Nombre",
+3
View File
@@ -241,6 +241,9 @@
"failed_to_load_organizations": "Échec du chargement des organisations",
"failed_to_load_workspaces": "Échec du chargement des projets",
"field_placeholder": "Espace réservé {{field}}",
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
"file_storage_not_set_up": "File storage not set up",
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filtre",
"finish": "Terminer",
"first_name": "Prénom",
+3
View File
@@ -241,6 +241,9 @@
"failed_to_load_organizations": "Nem sikerült betölteni a szervezeteket",
"failed_to_load_workspaces": "Nem sikerült a munkaterületek betöltése",
"field_placeholder": "{{field}} helykitöltője",
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
"file_storage_not_set_up": "File storage not set up",
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Szűrő",
"finish": "Befejezés",
"first_name": "Keresztnév",
+3
View File
@@ -241,6 +241,9 @@
"failed_to_load_organizations": "組織の読み込みに失敗しました",
"failed_to_load_workspaces": "ワークスペースの読み込みに失敗しました",
"field_placeholder": "{{field}} プレースホルダー",
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
"file_storage_not_set_up": "File storage not set up",
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "フィルター",
"finish": "完了",
"first_name": "名",
+3
View File
@@ -241,6 +241,9 @@
"failed_to_load_organizations": "Laden van organisaties mislukt",
"failed_to_load_workspaces": "Laden van werkruimtes mislukt",
"field_placeholder": "Tijdelijke aanduiding voor {{field}}",
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
"file_storage_not_set_up": "File storage not set up",
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filter",
"finish": "Finish",
"first_name": "Voornaam",
+3
View File
@@ -241,6 +241,9 @@
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_workspaces": "Falha ao carregar projetos",
"field_placeholder": "Espaço reservado de {{field}}",
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
"file_storage_not_set_up": "File storage not set up",
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filtro",
"finish": "Terminar",
"first_name": "Primeiro nome",
+3
View File
@@ -241,6 +241,9 @@
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_workspaces": "Falha ao carregar projetos",
"field_placeholder": "Espaço reservado de {{field}}",
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
"file_storage_not_set_up": "File storage not set up",
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filtro",
"finish": "Concluir",
"first_name": "Primeiro nome",
+3
View File
@@ -241,6 +241,9 @@
"failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor",
"failed_to_load_workspaces": "Nu s-au putut încărca workspaces",
"field_placeholder": "Substituent {{field}}",
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
"file_storage_not_set_up": "File storage not set up",
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filtru",
"finish": "Finalizează",
"first_name": "Prenume",
+3
View File
@@ -241,6 +241,9 @@
"failed_to_load_organizations": "Не удалось загрузить организации",
"failed_to_load_workspaces": "Не удалось загрузить рабочие пространства",
"field_placeholder": "Заполнитель {{field}}",
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
"file_storage_not_set_up": "File storage not set up",
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Фильтр",
"finish": "Завершить",
"first_name": "Имя",
+3
View File
@@ -241,6 +241,9 @@
"failed_to_load_organizations": "Misslyckades att ladda organisationer",
"failed_to_load_workspaces": "Det gick inte att ladda arbetsytor",
"field_placeholder": "Platshållare för {{field}}",
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
"file_storage_not_set_up": "File storage not set up",
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filter",
"finish": "Slutför",
"first_name": "Förnamn",
+3
View File
@@ -241,6 +241,9 @@
"failed_to_load_organizations": "Organizasyonlar yüklenemedi",
"failed_to_load_workspaces": "Çalışma alanları yüklenemedi",
"field_placeholder": "{{field}} Yer Tutucu",
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
"file_storage_not_set_up": "File storage not set up",
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "Filtre",
"finish": "Bitir",
"first_name": "Ad",
+3
View File
@@ -241,6 +241,9 @@
"failed_to_load_organizations": "加载组织失败",
"failed_to_load_workspaces": "加载工作区失败",
"field_placeholder": "{{field}} 占位符",
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
"file_storage_not_set_up": "File storage not set up",
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "筛选",
"finish": "完成",
"first_name": "名字",
+3
View File
@@ -241,6 +241,9 @@
"failed_to_load_organizations": "無法載入組織",
"failed_to_load_workspaces": "載入工作區失敗",
"field_placeholder": "{{field}} 預設文字",
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
"file_storage_not_set_up": "File storage not set up",
"file_upload_service_unavailable": "File upload service unavailable",
"filter": "篩選",
"finish": "完成",
"first_name": "名字",
@@ -18,6 +18,7 @@ import {
updateOrganizationEmailLogoUrlAction,
} from "@/modules/ee/whitelabel/email-customization/actions";
import { handleFileUpload } from "@/modules/storage/file-upload";
import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Uploader } from "@/modules/ui/components/file-input/components/uploader";
@@ -136,7 +137,7 @@ export const EmailCustomizationSettings = ({
const { url, error } = await handleFileUpload(logoFile, environmentId, allowedFileExtensions);
if (error) {
toast.error(error);
showFileUploadErrorToast(error, t);
setIsSaving(false);
return;
}
@@ -14,6 +14,7 @@ import {
updateOrganizationFaviconUrlAction,
} from "@/modules/ee/whitelabel/favicon-customization/actions";
import { handleFileUpload } from "@/modules/storage/file-upload";
import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { FileInput } from "@/modules/ui/components/file-input";
@@ -58,7 +59,7 @@ export const FaviconCustomizationSettings = ({
try {
const uploadResult = await handleFileUpload(file, environmentId, allowedFileExtensions);
if (uploadResult.error) {
toast.error(uploadResult.error);
showFileUploadErrorToast(uploadResult.error, t);
return;
}
setFaviconUrl(uploadResult.url);
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateProjectAction } from "@/modules/projects/settings/actions";
import { handleFileUpload } from "@/modules/storage/file-upload";
import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
@@ -39,7 +40,7 @@ export const EditLogo = ({ project, environmentId, isReadOnly, isStorageConfigur
try {
const uploadResult = await handleFileUpload(file, environmentId);
if (uploadResult.error) {
toast.error(uploadResult.error);
showFileUploadErrorToast(uploadResult.error, t);
return;
}
setLogoUrl(uploadResult.url);
@@ -0,0 +1,36 @@
"use client";
import { type TFunction } from "i18next";
import toast from "react-hot-toast";
import { FileUploadError } from "@/modules/storage/file-upload";
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
export const getFileUploadErrorMessage = (error: FileUploadError, t: TFunction): string => {
switch (error) {
case FileUploadError.NO_FILE:
return t("common.no_files_uploaded");
case FileUploadError.INVALID_FILE_TYPE:
return t("common.invalid_file_type");
case FileUploadError.FILE_SIZE_EXCEEDED:
return t("common.file_size_must_be_less_than_5_mb");
case FileUploadError.INVALID_FILE_NAME:
return t("common.invalid_file_name");
case FileUploadError.UPLOAD_FAILED:
default:
return t("common.upload_failed");
}
};
export const showFileUploadErrorToast = (error: FileUploadError, t: TFunction): void => {
if (error === FileUploadError.STORAGE_NOT_CONFIGURED) {
showStorageNotConfiguredToast("notConfigured");
return;
}
if (error === FileUploadError.STORAGE_UPLOAD_FAILED) {
showStorageNotConfiguredToast("uploadUnavailable");
return;
}
toast.error(getFileUploadErrorMessage(error, t));
};
+64 -3
View File
@@ -67,7 +67,41 @@ describe("fileUpload", () => {
});
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBe("Upload failed. Please try again.");
expect(result.error).toBe(fileUploadModule.FileUploadError.UPLOAD_FAILED);
expect(result.url).toBe("");
});
test("should return STORAGE_NOT_CONFIGURED when signing API returns a storage configuration error", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
json: async () => ({
code: "internal_server_error",
message: "File storage is not configured correctly. Please check your file upload settings.",
details: { storage_error_code: "s3_credentials_error" },
}),
});
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBe(fileUploadModule.FileUploadError.STORAGE_NOT_CONFIGURED);
expect(result.url).toBe("");
});
test("should return INVALID_FILE_NAME when signing API rejects the file name", async () => {
const file = createMockFile("----.jpg", "image/jpeg", 1000);
mockFetch.mockResolvedValueOnce({
ok: false,
status: 400,
json: async () => ({ details: { fileName: "Invalid file name" } }),
});
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBe(fileUploadModule.FileUploadError.INVALID_FILE_NAME);
expect(result.url).toBe("");
});
@@ -129,7 +163,7 @@ describe("fileUpload", () => {
}, 0);
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBe("Upload failed. Please try again.");
expect(result.error).toBe(fileUploadModule.FileUploadError.UPLOAD_FAILED);
expect(result.url).toBe("");
});
@@ -161,7 +195,34 @@ describe("fileUpload", () => {
}, 0);
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBe("Upload failed. Please try again.");
expect(result.error).toBe(fileUploadModule.FileUploadError.STORAGE_UPLOAD_FAILED);
expect(result.url).toBe("");
});
test("should return STORAGE_UPLOAD_FAILED when storage upload request throws", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
signedUrl: "https://s3.example.com/upload",
fileUrl: "/storage/test-env/public/file.jpg",
presignedFields: {
key: "value",
},
},
}),
});
mockFetch.mockRejectedValueOnce(new Error("Network error"));
setTimeout(() => {
mockFileReader.onload();
}, 0);
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBe(fileUploadModule.FileUploadError.STORAGE_UPLOAD_FAILED);
expect(result.url).toBe("");
});
+58 -21
View File
@@ -1,11 +1,48 @@
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 5 MB.",
UPLOAD_FAILED = "Upload failed. Please try again.",
INVALID_FILE_NAME = "Invalid file name. Please rename your file and try again.",
NO_FILE = "no_file",
INVALID_FILE_TYPE = "invalid_file_type",
FILE_SIZE_EXCEEDED = "file_size_exceeded",
UPLOAD_FAILED = "upload_failed",
INVALID_FILE_NAME = "invalid_file_name",
STORAGE_NOT_CONFIGURED = "storage_not_configured",
STORAGE_UPLOAD_FAILED = "storage_upload_failed",
}
type UploadApiErrorResponse = {
details?: {
fileName?: string;
storage_error_code?: string;
};
};
const storageConfigurationErrorCodes = new Set(["s3_credentials_error", "s3_client_error"]);
const parseUploadApiError = async (response: Response): Promise<UploadApiErrorResponse | undefined> => {
try {
return (await response.json()) as UploadApiErrorResponse;
} catch {
return undefined;
}
};
const getFileUploadErrorFromResponse = async (response: Response): Promise<FileUploadError> => {
const json = await parseUploadApiError(response);
if (response.status === 400 && json?.details?.fileName) {
return FileUploadError.INVALID_FILE_NAME;
}
if (
response.status >= 500 &&
json?.details?.storage_error_code &&
storageConfigurationErrorCodes.has(json.details.storage_error_code)
) {
return FileUploadError.STORAGE_NOT_CONFIGURED;
}
return FileUploadError.UPLOAD_FAILED;
};
export const toBase64 = (file: File) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -61,18 +98,8 @@ export const handleFileUpload = async (
});
if (!response.ok) {
if (response.status === 400) {
const json = (await response.json()) as { details?: { fileName?: string } };
if (json.details?.fileName) {
return {
error: FileUploadError.INVALID_FILE_NAME,
url: "",
};
}
}
return {
error: FileUploadError.UPLOAD_FAILED,
error: await getFileUploadErrorFromResponse(response),
url: "",
};
}
@@ -107,14 +134,24 @@ export const handleFileUpload = async (
};
}
const uploadResponse = await fetch(signedUrl, {
method: "POST",
body: formDataForS3,
});
let uploadResponse: Response;
try {
uploadResponse = await fetch(signedUrl, {
method: "POST",
body: formDataForS3,
});
} catch (err) {
console.error("Error in uploading file: ", err);
return {
error: FileUploadError.STORAGE_UPLOAD_FAILED,
url: "",
};
}
if (!uploadResponse.ok) {
return {
error: FileUploadError.UPLOAD_FAILED,
error: FileUploadError.STORAGE_UPLOAD_FAILED,
url: "",
};
}
+11 -1
View File
@@ -98,7 +98,9 @@ describe("storage utils", () => {
);
const spyISE = vi
.spyOn(responseMod.responses, "internalServerErrorResponse")
.mockImplementation((_msg: string, _public?: boolean) => new Response(null, { status: 500 }));
.mockImplementation((msg: string, _public?: boolean, _cache?: string, details = {}) =>
Response.json({ code: "internal_server_error", message: msg, details }, { status: 500 })
);
const { getErrorResponseFromStorageError } = await import("@/modules/storage/utils");
@@ -120,8 +122,16 @@ describe("storage utils", () => {
// S3 related and Unknown -> 500
const r500a = getErrorResponseFromStorageError({ code: StorageErrorCode.S3ClientError });
expect(r500a.status).toBe(500);
await expect(r500a.json()).resolves.toMatchObject({
message: "File storage is not configured correctly. Please check your file upload settings.",
details: { storage_error_code: StorageErrorCode.S3ClientError },
});
const r500b = getErrorResponseFromStorageError({ code: StorageErrorCode.S3CredentialsError });
expect(r500b.status).toBe(500);
await expect(r500b.json()).resolves.toMatchObject({
message: "File storage is not configured correctly. Please check your file upload settings.",
details: { storage_error_code: StorageErrorCode.S3CredentialsError },
});
const r500c = getErrorResponseFromStorageError({ code: StorageErrorCode.Unknown });
expect(r500c.status).toBe(500);
+12 -2
View File
@@ -121,9 +121,19 @@ export const getErrorResponseFromStorageError = (
case StorageErrorCode.InvalidInput:
return responses.badRequestResponse("Invalid input", details, true);
case StorageErrorCode.S3ClientError:
return responses.internalServerErrorResponse("Internal server error", true);
return responses.internalServerErrorResponse(
"File storage is not configured correctly. Please check your file upload settings.",
true,
"private, no-store",
{ storage_error_code: error.code }
);
case StorageErrorCode.S3CredentialsError:
return responses.internalServerErrorResponse("Internal server error", true);
return responses.internalServerErrorResponse(
"File storage is not configured correctly. Please check your file upload settings.",
true,
"private, no-store",
{ storage_error_code: error.code }
);
case StorageErrorCode.Unknown:
return responses.internalServerErrorResponse("Internal server error", true);
default: {
@@ -12,6 +12,7 @@ import { TProjectStyling } from "@formbricks/types/project";
import { TSurveyStyling } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { handleFileUpload } from "@/modules/storage/file-upload";
import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { ColorPicker } from "@/modules/ui/components/color-picker";
@@ -81,7 +82,7 @@ export const LogoSettingsCard = ({
try {
const uploadResult = await handleFileUpload(file, environmentId);
if (uploadResult.error) {
toast.error(t("common.upload_failed"));
showFileUploadErrorToast(uploadResult.error, t);
return;
}
setLogoUrl(uploadResult.url);
@@ -7,7 +7,8 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TAllowedFileExtension } from "@formbricks/types/storage";
import { cn } from "@/lib/cn";
import { FileUploadError, handleFileUpload } from "@/modules/storage/file-upload";
import { handleFileUpload } from "@/modules/storage/file-upload";
import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error";
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";
@@ -93,10 +94,10 @@ export const FileInput = ({
if (uploadedFiles.length < allowedFiles.length || uploadedFiles.some((file) => file.error)) {
const firstError = uploadedFiles.find((f) => f.error)?.error;
if (firstError === FileUploadError.INVALID_FILE_NAME) {
toast.error(t("common.invalid_file_name"));
} else if (uploadedFiles.length === 0) {
if (uploadedFiles.length === 0) {
toast.error(t("common.no_files_uploaded"));
} else if (firstError) {
showFileUploadErrorToast(firstError, t);
} else {
toast.error(t("common.some_files_failed_to_upload"));
}
@@ -167,10 +168,10 @@ export const FileInput = ({
if (uploadedFiles.length < allowedFiles.length || uploadedFiles.some((file) => file.error)) {
const firstError = uploadedFiles.find((f) => f.error)?.error;
if (firstError === FileUploadError.INVALID_FILE_NAME) {
toast.error(t("common.invalid_file_name"));
} else if (uploadedFiles.length === 0) {
if (uploadedFiles.length === 0) {
toast.error(t("common.no_files_uploaded"));
} else if (firstError) {
showFileUploadErrorToast(firstError, t);
} else {
toast.error(t("common.some_files_failed_to_upload"));
}
@@ -1,15 +1,27 @@
"use client";
export const StorageNotConfiguredToast = () => {
import { useTranslation } from "react-i18next";
interface StorageNotConfiguredToastProps {
variant?: "notConfigured" | "uploadUnavailable";
}
export const StorageNotConfiguredToast = ({ variant = "notConfigured" }: StorageNotConfiguredToastProps) => {
const { t } = useTranslation();
return (
<div className="flex w-fit !max-w-md items-center justify-center gap-2">
<span className="text-slate-900">File storage not set up</span>
<span className="text-slate-900">
{variant === "uploadUnavailable"
? t("common.file_upload_service_unavailable")
: t("common.file_storage_not_set_up")}
</span>
<a
className="text-slate-900 underline"
href="https://formbricks.com/docs/self-hosting/configuration/file-uploads"
target="_blank"
rel="noopener noreferrer">
Learn more
{t("common.learn_more")}
</a>
</div>
);
@@ -1,8 +1,10 @@
import toast from "react-hot-toast";
import { StorageNotConfiguredToast } from "../index";
export const showStorageNotConfiguredToast = () => {
return toast.error(() => <StorageNotConfiguredToast />, {
export const showStorageNotConfiguredToast = (
variant: "notConfigured" | "uploadUnavailable" = "notConfigured"
) => {
return toast.error(() => <StorageNotConfiguredToast variant={variant} />, {
id: "storage-not-configured-toast",
});
};
+1
View File
@@ -50,6 +50,7 @@
"only_one_file_can_be_uploaded_at_a_time": "يمكن تحميل ملف واحد فقط في المرة الواحدة.",
"placeholder_text": "انقر أو اسحب لرفع الملفات",
"upload_failed": "فشل التحميل! يرجى المحاولة مرة أخرى.",
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
"uploading": "جارٍ الرفع...",
"you_can_only_upload_a_maximum_of_files": "يمكنك تحميل {FILE_LIMIT} ملفات كحد أقصى."
},
+1
View File
@@ -50,6 +50,7 @@
"only_one_file_can_be_uploaded_at_a_time": "Du kan kun uploade én fil ad gangen.",
"placeholder_text": "Klik eller træk for at uploade filer",
"upload_failed": "Upload mislykkedes! Prøv venligst igen.",
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
"uploading": "Uploader...",
"you_can_only_upload_a_maximum_of_files": "Du kan maksimalt uploade {FILE_LIMIT} filer."
},
+1
View File
@@ -50,6 +50,7 @@
"only_one_file_can_be_uploaded_at_a_time": "Es kann nur eine Datei gleichzeitig hochgeladen werden.",
"placeholder_text": "Klicke oder ziehe Dateien hierher zum Hochladen",
"upload_failed": "Upload fehlgeschlagen! Bitte versuchen Sie es erneut.",
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
"uploading": "Wird hochgeladen...",
"you_can_only_upload_a_maximum_of_files": "Sie können maximal {FILE_LIMIT} Dateien hochladen."
},
+1
View File
@@ -50,6 +50,7 @@
"only_one_file_can_be_uploaded_at_a_time": "Only one file can be uploaded at a time.",
"placeholder_text": "Click or drag to upload files",
"upload_failed": "Upload failed! Please try again.",
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
"uploading": "Uploading...",
"you_can_only_upload_a_maximum_of_files": "You can only upload a maximum of {FILE_LIMIT} files."
},
+1
View File
@@ -50,6 +50,7 @@
"only_one_file_can_be_uploaded_at_a_time": "Solo se puede subir un archivo a la vez.",
"placeholder_text": "Haz clic o arrastra para subir archivos",
"upload_failed": "¡Subida fallida! Por favor, inténtalo de nuevo.",
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
"uploading": "Subiendo...",
"you_can_only_upload_a_maximum_of_files": "Solo puedes subir un máximo de {FILE_LIMIT} archivos."
},
+1
View File
@@ -50,6 +50,7 @@
"only_one_file_can_be_uploaded_at_a_time": "Korraga saab üles laadida ainult ühe faili.",
"placeholder_text": "Klõpsa või lohista failide üleslaadimiseks",
"upload_failed": "Üleslaadimine ebaõnnestus! Palun proovi uuesti.",
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
"uploading": "Üleslaadimine...",
"you_can_only_upload_a_maximum_of_files": "Saad üles laadida maksimaalselt {FILE_LIMIT} faili."
},
+1
View File
@@ -50,6 +50,7 @@
"only_one_file_can_be_uploaded_at_a_time": "Un seul fichier peut être téléchargé à la fois.",
"placeholder_text": "Cliquez ou glissez pour télécharger des fichiers",
"upload_failed": "Échec du téléchargement ! Veuillez réessayer.",
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
"uploading": "Téléchargement en cours...",
"you_can_only_upload_a_maximum_of_files": "Vous ne pouvez télécharger qu'un maximum de {FILE_LIMIT} fichiers."
},
+1
View File
@@ -50,6 +50,7 @@
"only_one_file_can_be_uploaded_at_a_time": "एक समय में केवल एक फ़ाइल अपलोड की जा सकती है।",
"placeholder_text": "फ़ाइलें अपलोड करने के लिए क्लिक करें या ड्रैग करें",
"upload_failed": "अपलोड विफल! कृपया पुनः प्रयास करें।",
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
"uploading": "अपलोड हो रहा है...",
"you_can_only_upload_a_maximum_of_files": "आप अधिकतम {FILE_LIMIT} फ़ाइलें ही अपलोड कर सकते हैं।"
},
+1
View File
@@ -50,6 +50,7 @@
"only_one_file_can_be_uploaded_at_a_time": "Egyszerre csak egy fájl tölthető fel.",
"placeholder_text": "Kattintson vagy húzza ide a fájlok feltöltéséhez",
"upload_failed": "A feltöltés nem sikerült! Próbálja meg újra.",
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
"uploading": "Feltöltés…",
"you_can_only_upload_a_maximum_of_files": "Legfeljebb csak {FILE_LIMIT} fájlt tölthet fel."
},
+1
View File
@@ -50,6 +50,7 @@
"only_one_file_can_be_uploaded_at_a_time": "È possibile caricare solo un file alla volta.",
"placeholder_text": "Clicca o trascina per caricare i file",
"upload_failed": "Caricamento fallito! Riprova.",
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
"uploading": "Caricamento in corso...",
"you_can_only_upload_a_maximum_of_files": "Puoi caricare un massimo di {FILE_LIMIT} file."
},
+1
View File
@@ -50,6 +50,7 @@
"only_one_file_can_be_uploaded_at_a_time": "一度にアップロードできるファイルは1つだけです。",
"placeholder_text": "クリックまたはドラッグしてファイルをアップロード",
"upload_failed": "アップロードに失敗しました!もう一度お試しください。",
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
"uploading": "アップロード中...",
"you_can_only_upload_a_maximum_of_files": "アップロードできるファイルは最大{FILE_LIMIT}個までです。"
},
+1
View File
@@ -50,6 +50,7 @@
"only_one_file_can_be_uploaded_at_a_time": "Er kan slechts één bestand tegelijk worden geüpload.",
"placeholder_text": "Klik of sleep bestanden om te uploaden",
"upload_failed": "Uploaden mislukt! Probeer het opnieuw.",
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
"uploading": "Uploaden...",
"you_can_only_upload_a_maximum_of_files": "Je kunt maximaal {FILE_LIMIT} bestanden uploaden."
},
+1
View File
@@ -50,6 +50,7 @@
"only_one_file_can_be_uploaded_at_a_time": "Apenas um arquivo pode ser carregado de cada vez.",
"placeholder_text": "Clique ou arraste para enviar ficheiros",
"upload_failed": "Falha no carregamento! Por favor, tente novamente.",
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
"uploading": "A enviar...",
"you_can_only_upload_a_maximum_of_files": "Você só pode carregar um máximo de {FILE_LIMIT} arquivos."
},
+1
View File
@@ -50,6 +50,7 @@
"only_one_file_can_be_uploaded_at_a_time": "Poți încărca doar un singur fișier odată.",
"placeholder_text": "Apasă sau trage pentru a încărca fișiere",
"upload_failed": "Încărcarea a eșuat! Te rugăm să încerci din nou.",
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
"uploading": "Se încarcă...",
"you_can_only_upload_a_maximum_of_files": "Poți încărca un număr maxim de {FILE_LIMIT} fișiere."
},
+1
View File
@@ -50,6 +50,7 @@
"only_one_file_can_be_uploaded_at_a_time": "Можно загрузить только один файл за раз.",
"placeholder_text": "Нажмите или перетащите файлы для загрузки",
"upload_failed": "Ошибка загрузки! Пожалуйста, попробуйте снова.",
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
"uploading": "Загрузка...",
"you_can_only_upload_a_maximum_of_files": "Вы можете загрузить максимум {FILE_LIMIT} файлов."
},
+1
View File
@@ -50,6 +50,7 @@
"only_one_file_can_be_uploaded_at_a_time": "Endast en fil kan laddas upp åt gången.",
"placeholder_text": "Klicka eller dra för att ladda upp filer",
"upload_failed": "Uppladdning misslyckades! Försök igen.",
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
"uploading": "Laddar upp...",
"you_can_only_upload_a_maximum_of_files": "Du kan ladda upp maximalt {FILE_LIMIT} filer."
},
+1
View File
@@ -50,6 +50,7 @@
"only_one_file_can_be_uploaded_at_a_time": "Aynı anda yalnızca bir dosya yüklenebilir.",
"placeholder_text": "Dosyaları yüklemek için tıklayın veya sürükleyin",
"upload_failed": "Yükleme başarısız oldu! Lütfen tekrar deneyin.",
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
"uploading": "Yükleniyor...",
"you_can_only_upload_a_maximum_of_files": "En fazla {FILE_LIMIT} dosya yükleyebilirsiniz."
},
+1
View File
@@ -50,6 +50,7 @@
"only_one_file_can_be_uploaded_at_a_time": "Bir vaqtning o'zida faqat bitta fayl yuklanishi mumkin.",
"placeholder_text": "Fayllarni yuklash uchun bosing yoki sudrab olib keling",
"upload_failed": "Yuklash muvaffaqiyatsiz tugadi! Iltimos, qayta urinib ko'ring.",
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
"uploading": "Yuklanmoqda...",
"you_can_only_upload_a_maximum_of_files": "Siz faqat {FILE_LIMIT} ta faylni maksimal yuklashingiz mumkin."
},
+1
View File
@@ -50,6 +50,7 @@
"only_one_file_can_be_uploaded_at_a_time": "一次只能上传一个文件。",
"placeholder_text": "点击或拖拽上传文件",
"upload_failed": "上传失败!请重试。",
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
"uploading": "上传中...",
"you_can_only_upload_a_maximum_of_files": "您最多只能上传 {FILE_LIMIT} 个文件。"
},
@@ -288,6 +288,8 @@ export function FileUploadElement({
setFileErrorMessage(err.message);
} else if (err?.name === "InvalidFileNameError") {
setFileErrorMessage(t("errors.file_input.upload_failed"));
} else if (err?.name === "StorageNotConfiguredError" || err?.name === "StorageUploadFailedError") {
setFileErrorMessage(t("errors.file_input.upload_service_unavailable"));
} else {
setFileErrorMessage(t("errors.file_input.upload_failed"));
}
@@ -202,6 +202,26 @@ describe("ApiClient", () => {
).rejects.toThrow("Invalid file name");
});
test("throws StorageNotConfiguredError if signing fails because storage is not configured", async () => {
vi.mocked(global.fetch).mockResolvedValueOnce({
ok: false,
status: 500,
json: async () => ({
code: "internal_server_error",
message: "File storage is not configured correctly. Please check your file upload settings.",
details: { storage_error_code: "s3_client_error" },
}),
} as unknown as Response);
await expect(() =>
client.uploadFile({
base64: "data:image/jpeg;base64,abcd",
name: "test.jpg",
type: "image/jpeg",
})
).rejects.toMatchObject({ name: "StorageNotConfiguredError" });
});
test("throws an error if actual upload fails", async () => {
vi.mocked(global.fetch)
.mockResolvedValueOnce({
@@ -230,6 +250,31 @@ describe("ApiClient", () => {
).rejects.toThrow("Upload failed with status: 500");
});
test("throws StorageUploadFailedError if actual upload request fails before response", async () => {
vi.mocked(global.fetch)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
signedUrl: "https://fake-s3-url.com",
fileUrl: "https://fake-file-url.com",
presignedFields: { policy: "test" },
signingData: null,
updatedFileName: "test.jpg",
},
}),
} as unknown as Response)
.mockRejectedValueOnce(new Error("Network error"));
await expect(() =>
client.uploadFile({
base64: "data:image/jpeg;base64,abcd",
name: "test.jpg",
type: "image/jpeg",
})
).rejects.toMatchObject({ name: "StorageUploadFailedError" });
});
test('throws "Error uploading file" if base64 is invalid', async () => {
// Mock the initial "signing" fetch to succeed
vi.mocked(global.fetch).mockResolvedValueOnce({
+39 -9
View File
@@ -23,6 +23,23 @@ type TResponseCreateResponse = {
type TResponseUpdateResponse = Record<string, unknown> & TResponseQuota;
type TUploadApiErrorResponse = ApiErrorResponse & {
details?: ApiErrorResponse["details"] & {
storage_error_code?: string;
fileName?: string;
};
};
const storageConfigurationErrorCodes = new Set(["s3_credentials_error", "s3_client_error"]);
const parseUploadErrorResponse = async (response: Response): Promise<TUploadApiErrorResponse | undefined> => {
try {
return (await response.json()) as TUploadApiErrorResponse;
} catch {
return undefined;
}
};
// Simple API client using fetch
export class ApiClient {
readonly appUrl: string;
@@ -121,13 +138,22 @@ export class ApiClient {
});
if (!response.ok) {
if (response.status === 400) {
const json = (await response.json()) as ApiErrorResponse;
if (json.details?.fileName) {
const err = new Error("Invalid file name");
err.name = "InvalidFileNameError";
throw err;
}
const json = await parseUploadErrorResponse(response);
if (response.status === 400 && json?.details?.fileName) {
const err = new Error("Invalid file name");
err.name = "InvalidFileNameError";
throw err;
}
if (
response.status >= 500 &&
json?.details?.storage_error_code &&
storageConfigurationErrorCodes.has(json.details.storage_error_code)
) {
const err = new Error("File upload service is not configured");
err.name = "StorageNotConfiguredError";
throw err;
}
throw new Error(`Upload failed with status: ${String(response.status)}`);
@@ -173,7 +199,9 @@ export class ApiClient {
});
} catch (err) {
console.error("Error uploading file", err);
throw new Error("Network error while uploading file");
const error = new Error("File upload service is unavailable");
error.name = "StorageUploadFailedError";
throw error;
}
if (!uploadResponse.ok) {
@@ -185,7 +213,9 @@ export class ApiClient {
throw error;
}
throw new Error(`Upload failed with status: ${String(uploadResponse.status)}`);
const error = new Error(`Upload failed with status: ${String(uploadResponse.status)}`);
error.name = "StorageUploadFailedError";
throw error;
}
return fileUrl;