Compare commits

..

3 Commits

Author SHA1 Message Date
Matti Nannt b9dcc5dd4b docs: restore Vercel reference in hubspot webhook guide
Vercel mention is about user-built webhook servers, not Formbricks
deployment — keeping it is correct.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:56:05 +02:00
Matti Nannt d7fbb439e5 chore: remove all Vercel deployment references
Formbricks no longer deploys to Vercel and does not support it for
self-hosters. This removes all Vercel-specific deployment config,
environment variables, and infrastructure references from the codebase.

- Delete vercel.json and .vercelignore
- Remove VERCEL_URL env var from env.ts, constants.ts, getPublicUrl.ts
- Remove X-Vercel-IP-Country header from v1 and v2 response API routes
- Remove VERCEL / VERCEL_URL from turbo.json build env passthrough
- Update .env.example and docker-compose.yml comments
- Update sentry.edge.config.ts comment
- Remove VERCEL_URL from license.test.ts and getPublicUrl.test.ts mocks
- Update docs to remove Vercel deployment references

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:46:40 +02:00
Bhagya Amarasinghe 535c111860 fix: improve file upload storage errors (#7978) 2026-05-13 09:25:04 +00:00
70 changed files with 453 additions and 138 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ SMTP_PASSWORD=smtpPassword
# S3 STORAGE #
##############
# S3 Storage is required for the file upload in serverless environments like Vercel
# S3 Storage is required for the file upload in serverless environments
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_REGION=
-1
View File
@@ -1 +0,0 @@
apps/web/.env
@@ -96,10 +96,7 @@ export const POST = withV1ApiWrapper({
const agent = new UAParser(userAgent);
const country =
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
requestHeaders.get("CF-IPCountry") || requestHeaders.get("CloudFront-Viewer-Country") || undefined;
const responseInputData = responseInputValidation.data;
@@ -35,10 +35,7 @@ type TValidatedResponseInputResult =
| { response: Response };
const getCountry = (requestHeaders: Headers): string | undefined =>
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
requestHeaders.get("CF-IPCountry") || requestHeaders.get("CloudFront-Viewer-Country") || undefined;
const getUnexpectedPublicErrorResponse = (): Response =>
responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
+1 -1
View File
@@ -152,7 +152,7 @@ describe("API Response Utilities", () => {
test("should use custom cache control header when provided", () => {
const message = "Something went wrong";
const customCache = "no-cache";
const response = responses.internalServerErrorResponse(message, false, customCache);
const response = responses.internalServerErrorResponse(message, false, {}, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
+2 -1
View File
@@ -217,6 +217,7 @@ const successResponse = (data: Object, cors: boolean = false, cache: string = "p
const internalServerErrorResponse = (
message: string,
cors: boolean = false,
details: ApiErrorResponse["details"] = {},
cache: string = "private, no-store"
) => {
const headers = {
@@ -228,7 +229,7 @@ const internalServerErrorResponse = (
{
code: "internal_server_error",
message,
details: {},
details,
} as ApiErrorResponse,
{
status: 500,
+1 -2
View File
@@ -10,8 +10,7 @@ export const IS_DEVELOPMENT = env.NODE_ENV === "development";
export const E2E_TESTING = env.E2E_TESTING === "1";
// URLs
export const WEBAPP_URL =
env.WEBAPP_URL || (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : false) || "http://localhost:3000";
export const WEBAPP_URL = env.WEBAPP_URL || "http://localhost:3000";
// encryption keys
export const ENCRYPTION_KEY = env.ENCRYPTION_KEY;
-2
View File
@@ -235,7 +235,6 @@ const parsedEnv = createEnv({
TURNSTILE_SITE_KEY: z.string().optional(),
RECAPTCHA_SITE_KEY: z.string().optional(),
RECAPTCHA_SECRET_KEY: z.string().optional(),
VERCEL_URL: z.string().optional(),
WEBAPP_URL: z.url().optional(),
UNSPLASH_ACCESS_KEY: z.string().optional(),
@@ -354,7 +353,6 @@ const parsedEnv = createEnv({
RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,
RECAPTCHA_SECRET_KEY: process.env.RECAPTCHA_SECRET_KEY,
TERMS_URL: process.env.TERMS_URL,
VERCEL_URL: process.env.VERCEL_URL,
WEBAPP_URL: process.env.WEBAPP_URL,
UNSPLASH_ACCESS_KEY: process.env.UNSPLASH_ACCESS_KEY,
NODE_ENV: process.env.NODE_ENV,
+1 -12
View File
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
const envMock = {
WEBAPP_URL: undefined as string | undefined,
VERCEL_URL: undefined as string | undefined,
PUBLIC_URL: undefined as string | undefined,
};
@@ -19,7 +18,6 @@ const loadGetPublicDomain = async () => {
describe("getPublicDomain", () => {
beforeEach(() => {
envMock.WEBAPP_URL = undefined;
envMock.VERCEL_URL = undefined;
envMock.PUBLIC_URL = undefined;
});
@@ -31,16 +29,7 @@ describe("getPublicDomain", () => {
expect(getPublicDomain()).toBe("https://app.formbricks.com");
});
test("falls back to VERCEL_URL when WEBAPP_URL is empty", async () => {
envMock.WEBAPP_URL = " ";
envMock.VERCEL_URL = "preview.formbricks.com";
const getPublicDomain = await loadGetPublicDomain();
expect(getPublicDomain()).toBe("https://preview.formbricks.com");
});
test("falls back to localhost when WEBAPP_URL and VERCEL_URL are not set", async () => {
test("falls back to localhost when WEBAPP_URL is not set", async () => {
const getPublicDomain = await loadGetPublicDomain();
expect(getPublicDomain()).toBe("http://localhost:3000");
+1 -11
View File
@@ -2,17 +2,7 @@ import "server-only";
import { env } from "./env";
const configuredWebappUrl = env.WEBAPP_URL?.trim() ?? "";
const WEBAPP_URL = (() => {
if (configuredWebappUrl !== "") {
return configuredWebappUrl;
}
if (env.VERCEL_URL) {
return `https://${env.VERCEL_URL}`;
}
return "http://localhost:3000";
})();
const WEBAPP_URL = configuredWebappUrl !== "" ? configuredWebappUrl : "http://localhost:3000";
/**
* Returns the public domain URL
+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": "名字",
+7 -5
View File
@@ -6,7 +6,6 @@ import { InvalidInputError, UnknownError } from "@formbricks/types/errors";
import { ZUser, ZUserEmail, ZUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
import { hashPassword } from "@/lib/auth";
import {
EMAIL_VERIFICATION_DISABLED,
IS_FORMBRICKS_CLOUD,
IS_TURNSTILE_CONFIGURED,
TURNSTILE_SECRET_KEY,
@@ -46,6 +45,7 @@ const ZCreateUserAction = z.object({
password: ZUserPassword,
inviteToken: z.string().optional(),
userLocale: ZUserLocale.optional(),
emailVerificationDisabled: z.boolean().optional(),
turnstileToken: z
.string()
.optional()
@@ -53,6 +53,7 @@ const ZCreateUserAction = z.object({
(token) => !IS_TURNSTILE_CONFIGURED || (IS_TURNSTILE_CONFIGURED && token),
"CAPTCHA verification required"
),
isFormbricksCloud: z.boolean(),
subscribeToSecurityUpdates: z.boolean().optional(),
subscribeToProductUpdates: z.boolean().optional(),
});
@@ -201,7 +202,8 @@ async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUs
async function handlePostUserCreation(
ctx: ActionClientCtx,
user: TCreatedUser,
inviteToken: string | undefined
inviteToken: string | undefined,
emailVerificationDisabled: boolean | undefined
): Promise<void> {
if (inviteToken) {
await handleInviteAcceptance(ctx, inviteToken, user);
@@ -209,7 +211,7 @@ async function handlePostUserCreation(
await handleOrganizationCreation(ctx, user);
}
if (!EMAIL_VERIFICATION_DISABLED) {
if (!emailVerificationDisabled) {
let inviteCallbackUrl: string | undefined;
if (inviteToken) {
@@ -241,11 +243,11 @@ export const createUserAction = actionClient.inputSchema(ZCreateUserAction).acti
);
if (!userAlreadyExisted && user) {
await handlePostUserCreation(ctx, user, parsedInput.inviteToken);
await handlePostUserCreation(ctx, user, parsedInput.inviteToken, parsedInput.emailVerificationDisabled);
await subscribeUserToMailingList({
email: user.email,
isFormbricksCloud: IS_FORMBRICKS_CLOUD,
isFormbricksCloud: parsedInput.isFormbricksCloud,
subscribeToSecurityUpdates: parsedInput.subscribeToSecurityUpdates,
subscribeToProductUpdates: parsedInput.subscribeToProductUpdates,
});
@@ -114,7 +114,9 @@ export const SignupForm = ({
password: data.password,
userLocale,
inviteToken: inviteToken ?? "",
emailVerificationDisabled,
turnstileToken,
isFormbricksCloud,
subscribeToSecurityUpdates,
subscribeToProductUpdates,
});
@@ -12,7 +12,7 @@ vi.mock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -378,7 +378,7 @@ describe("License Core Logic", () => {
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: "",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -410,7 +410,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -444,7 +444,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -475,7 +475,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -506,7 +506,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -571,7 +571,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -627,7 +627,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -683,7 +683,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -722,7 +722,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -748,7 +748,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -899,7 +899,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -946,7 +946,7 @@ describe("License Core Logic", () => {
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: undefined,
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -969,7 +969,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: testLicenseKey,
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -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";
@@ -138,7 +139,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,40 @@
"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.STORAGE_NOT_CONFIGURED:
return t("common.storage_not_configured");
case FileUploadError.STORAGE_UPLOAD_FAILED:
return t("common.file_upload_service_unavailable");
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));
};
+65 -3
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { STORAGE_ERROR_CODES } from "@formbricks/types/storage";
import * as fileUploadModule from "./file-upload";
// Mock global fetch
@@ -67,7 +68,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: STORAGE_ERROR_CODES.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 +164,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 +196,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("");
});
+55 -21
View File
@@ -1,11 +1,45 @@
import { STORAGE_CONFIGURATION_ERROR_CODES, type TStorageApiErrorDetails } from "@formbricks/types/storage";
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?: TStorageApiErrorDetails;
};
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 &&
STORAGE_CONFIGURATION_ERROR_CODES.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 +95,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 +131,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, 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);
+6 -2
View File
@@ -121,9 +121,13 @@ export const getErrorResponseFromStorageError = (
case StorageErrorCode.InvalidInput:
return responses.badRequestResponse("Invalid input", details, true);
case StorageErrorCode.S3ClientError:
return responses.internalServerErrorResponse("Internal server error", true);
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,
{ storage_error_code: error.code },
"private, no-store"
);
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 -1
View File
@@ -1,6 +1,6 @@
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// Note that this config is also required when running locally.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
-16
View File
@@ -1,16 +0,0 @@
{
"functions": {
"app/**/*.ts": {
"maxDuration": 10,
"memory": 512
},
"app/api/cron/**/*.ts": {
"maxDuration": 180,
"memory": 512
},
"app/api/v1/client/**/*.ts": {
"maxDuration": 10,
"memory": 200
}
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ x-environment: &environment
################################################### OPTIONAL (STORAGE) ###################################################
# Set S3 Storage configuration (required for the file upload in serverless environments like Vercel)
# Set S3 Storage configuration (required for the file upload in serverless environments)
# S3_ACCESS_KEY:
# S3_SECRET_KEY:
# S3_REGION:
@@ -6,7 +6,7 @@ icon: code
## TypeScript
Our codebase follows the Vercel Engineering Style Guide conventions.
Our codebase uses the `@vercel/style-guide` ESLint configurations for consistent code quality.
### ESLint Configuration
+1 -1
View File
@@ -1323,7 +1323,7 @@ Please note that their values and the logic remains exactly the same. Only the p
### Deprecated Environment Variables
- **`NEXT_PUBLIC_VERCEL_URL`**: Was used as Vercel URL (used instead of `WEBAPP_URL)`, but from v1.1, you can just set the `WEBAPP_URL` environment variable to your Vercel URL.
- **`NEXT_PUBLIC_VERCEL_URL`**: Was used as deployment URL fallback (used instead of `WEBAPP_URL`), but from v1.1, you can just set the `WEBAPP_URL` environment variable.
- **`RAILWAY_STATIC_URL`**: Was used as Railway Static URL (used instead of `WEBAPP_URL`), but from v1.1, you can just set the `WEBAPP_URL` environment variable.
+2
View File
@@ -46,10 +46,12 @@
"duplicate_files": "الملفات التالية تم تحميلها بالفعل: {duplicateNames}. لا يُسمح بالملفات المكررة.",
"file_size_exceeded": "الملف (الملفات) التالية تتجاوز الحجم الأقصى البالغ {maxSizeInMB} ميجابايت وتم إزالتها: {fileNames}",
"file_size_exceeded_alert": "يجب أن يكون حجم الملف أقل من {maxSizeInMB} ميجابايت",
"invalid_file_name": "يحتوي اسم الملف على أحرف غير صالحة. يُرجى إعادة تسمية الملف والمحاولة مرة أخرى.",
"no_valid_file_types_selected": "لم يتم اختيار أنواع ملفات صالحة. يرجى اختيار نوع ملف صالح.",
"only_one_file_can_be_uploaded_at_a_time": "يمكن تحميل ملف واحد فقط في المرة الواحدة.",
"placeholder_text": "انقر أو اسحب لرفع الملفات",
"upload_failed": "فشل التحميل! يرجى المحاولة مرة أخرى.",
"upload_service_unavailable": "خدمة تحميل الملفات غير متاحة. يُرجى المحاولة مرة أخرى لاحقًا أو التواصل مع مالك الاستبيان.",
"uploading": "جارٍ الرفع...",
"you_can_only_upload_a_maximum_of_files": "يمكنك تحميل {FILE_LIMIT} ملفات كحد أقصى."
},
+2
View File
@@ -46,10 +46,12 @@
"duplicate_files": "Følgende filer er allerede uploadet: {duplicateNames}. Duplikatfiler er ikke tilladt.",
"file_size_exceeded": "Følgende fil(er) overstiger den maksimale størrelse på {maxSizeInMB} MB og blev fjernet: {fileNames}",
"file_size_exceeded_alert": "Filen skal være mindre end {maxSizeInMB} MB",
"invalid_file_name": "Filnavnet indeholder ugyldige tegn. Omdøb filen, og prøv igen.",
"no_valid_file_types_selected": "Ingen gyldige filtyper valgt. Vælg venligst en gyldig filtype.",
"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": "Filuploadtjenesten er ikke tilgængelig. Prøv igen senere, eller kontakt undersøgelsens ejer.",
"uploading": "Uploader...",
"you_can_only_upload_a_maximum_of_files": "Du kan maksimalt uploade {FILE_LIMIT} filer."
},
+2
View File
@@ -46,10 +46,12 @@
"duplicate_files": "Die folgenden Dateien sind bereits hochgeladen: {duplicateNames}. Doppelte Dateien sind nicht erlaubt.",
"file_size_exceeded": "Die folgenden Dateien überschreiten die maximale Größe von {maxSizeInMB} MB und wurden entfernt: {fileNames}",
"file_size_exceeded_alert": "Die Datei sollte kleiner als {maxSizeInMB} MB sein",
"invalid_file_name": "Der Dateiname enthält ungültige Zeichen. Bitte benenne die Datei um und versuche es erneut.",
"no_valid_file_types_selected": "Keine gültigen Dateitypen ausgewählt. Bitte wählen Sie einen gültigen Dateityp.",
"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": "Der Datei-Upload-Dienst ist nicht verfügbar. Bitte versuche es später erneut oder kontaktiere den Umfragebesitzer.",
"uploading": "Wird hochgeladen...",
"you_can_only_upload_a_maximum_of_files": "Sie können maximal {FILE_LIMIT} Dateien hochladen."
},
+2
View File
@@ -46,10 +46,12 @@
"duplicate_files": "The following files are already uploaded: {duplicateNames}. Duplicate files are not allowed.",
"file_size_exceeded": "The following file(s) exceed the maximum size of {maxSizeInMB} MB and were removed: {fileNames}",
"file_size_exceeded_alert": "File should be less than {maxSizeInMB} MB",
"invalid_file_name": "The file name contains invalid characters. Please rename the file and try again.",
"no_valid_file_types_selected": "No valid file types selected. Please select a valid file type.",
"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."
},
+2
View File
@@ -46,10 +46,12 @@
"duplicate_files": "Los siguientes archivos ya están subidos: {duplicateNames}. No se permiten archivos duplicados.",
"file_size_exceeded": "Los siguientes archivos exceden el tamaño máximo de {maxSizeInMB} MB y fueron eliminados: {fileNames}",
"file_size_exceeded_alert": "El archivo debe ser menor de {maxSizeInMB} MB",
"invalid_file_name": "El nombre del archivo contiene caracteres no válidos. Cambia el nombre del archivo e inténtalo de nuevo.",
"no_valid_file_types_selected": "No se han seleccionado tipos de archivo válidos. Por favor, selecciona un tipo de archivo válido.",
"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": "El servicio de carga de archivos no está disponible. Inténtalo de nuevo más tarde o contacta con el propietario de la encuesta.",
"uploading": "Subiendo...",
"you_can_only_upload_a_maximum_of_files": "Solo puedes subir un máximo de {FILE_LIMIT} archivos."
},
+2
View File
@@ -46,10 +46,12 @@
"duplicate_files": "Järgmised failid on juba üles laaditud: {duplicateNames}. Duplikaatfailid ei ole lubatud.",
"file_size_exceeded": "Järgmised failid ületavad maksimaalse suuruse {maxSizeInMB} MB ja eemaldati: {fileNames}",
"file_size_exceeded_alert": "Fail peab olema väiksem kui {maxSizeInMB} MB",
"invalid_file_name": "Failinimi sisaldab sobimatuid märke. Nimeta fail ümber ja proovi uuesti.",
"no_valid_file_types_selected": "Ühtegi kehtivat failitüüpi pole valitud. Palun vali kehtiv failitüüp.",
"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": "Failide üleslaadimise teenus pole saadaval. Proovi hiljem uuesti või võta ühendust küsitluse omanikuga.",
"uploading": "Üleslaadimine...",
"you_can_only_upload_a_maximum_of_files": "Saad üles laadida maksimaalselt {FILE_LIMIT} faili."
},
+2
View File
@@ -46,10 +46,12 @@
"duplicate_files": "Les fichiers suivants sont déjà téléchargés : {duplicateNames}. Les fichiers en double ne sont pas autorisés.",
"file_size_exceeded": "Les fichiers suivants dépassent la taille maximale de {maxSizeInMB} Mo et ont été supprimés : {fileNames}",
"file_size_exceeded_alert": "Le fichier doit être inférieur à {maxSizeInMB} Mo",
"invalid_file_name": "Le nom du fichier contient des caractères non valides. Renommez le fichier et réessayez.",
"no_valid_file_types_selected": "Aucun type de fichier valide sélectionné. Veuillez sélectionner un type de fichier valide.",
"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": "Le service de téléversement de fichiers est indisponible. Réessayez plus tard ou contactez le propriétaire du sondage.",
"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."
},
+2
View File
@@ -46,10 +46,12 @@
"duplicate_files": "निम्नलिखित फ़ाइलें पहले से ही अपलोड की गई हैं: {duplicateNames}। डुप्लिकेट फ़ाइलों की अनुमति नहीं है।",
"file_size_exceeded": "निम्नलिखित फ़ाइल(ें) अधिकतम आकार {maxSizeInMB} MB से अधिक हैं और हटा दी गई हैं: {fileNames}",
"file_size_exceeded_alert": "फ़ाइल {maxSizeInMB} MB से कम होनी चाहिए",
"invalid_file_name": "फ़ाइल नाम में अमान्य वर्ण हैं। कृपया फ़ाइल का नाम बदलें और फिर से प्रयास करें।",
"no_valid_file_types_selected": "कोई मान्य फ़ाइल प्रकार नहीं चुना गया है। कृपया एक मान्य फ़ाइल प्रकार चुनें।",
"only_one_file_can_be_uploaded_at_a_time": "एक समय में केवल एक फ़ाइल अपलोड की जा सकती है।",
"placeholder_text": "फ़ाइलें अपलोड करने के लिए क्लिक करें या ड्रैग करें",
"upload_failed": "अपलोड विफल! कृपया पुनः प्रयास करें।",
"upload_service_unavailable": "फ़ाइल अपलोड सेवा उपलब्ध नहीं है। कृपया बाद में फिर से प्रयास करें या सर्वेक्षण के मालिक से संपर्क करें।",
"uploading": "अपलोड हो रहा है...",
"you_can_only_upload_a_maximum_of_files": "आप अधिकतम {FILE_LIMIT} फ़ाइलें ही अपलोड कर सकते हैं।"
},
+2
View File
@@ -46,10 +46,12 @@
"duplicate_files": "A következő fájlok már fel lettek töltve: {duplicateNames}. Kettőzött fájlok nem engedélyezettek.",
"file_size_exceeded": "A következő fájlok túllépik a legnagyobb, {maxSizeInMB} MB-os méretet, ezért eltávolításra kerültek: {fileNames}",
"file_size_exceeded_alert": "A fájlnak kisebbnek kell lennie mint {maxSizeInMB} MB",
"invalid_file_name": "A fájlnév érvénytelen karaktereket tartalmaz. Nevezd át a fájlt, majd próbáld újra.",
"no_valid_file_types_selected": "Nincs érvényes fájltípus kiválasztva. Válasszon egy érvényes fájltípust.",
"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": "A fájlfeltöltési szolgáltatás nem érhető el. Próbáld újra később, vagy vedd fel a kapcsolatot a felmérés tulajdonosával.",
"uploading": "Feltöltés…",
"you_can_only_upload_a_maximum_of_files": "Legfeljebb csak {FILE_LIMIT} fájlt tölthet fel."
},
+2
View File
@@ -46,10 +46,12 @@
"duplicate_files": "I seguenti file sono già caricati: {duplicateNames}. I file duplicati non sono consentiti.",
"file_size_exceeded": "I seguenti file superano la dimensione massima di {maxSizeInMB} MB e sono stati rimossi: {fileNames}",
"file_size_exceeded_alert": "Il file deve essere inferiore a {maxSizeInMB} MB",
"invalid_file_name": "Il nome del file contiene caratteri non validi. Rinomina il file e riprova.",
"no_valid_file_types_selected": "Nessun tipo di file valido selezionato. Seleziona un tipo di file valido.",
"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": "Il servizio di caricamento file non è disponibile. Riprova più tardi o contatta il proprietario del sondaggio. (da verificare)",
"uploading": "Caricamento in corso...",
"you_can_only_upload_a_maximum_of_files": "Puoi caricare un massimo di {FILE_LIMIT} file."
},
+2
View File
@@ -46,10 +46,12 @@
"duplicate_files": "以下のファイルはすでにアップロードされています:{duplicateNames}。重複ファイルは許可されていません。",
"file_size_exceeded": "以下のファイルは最大サイズ{maxSizeInMB}MBを超えているため削除されました:{fileNames}",
"file_size_exceeded_alert": "ファイルは{maxSizeInMB}MB未満である必要があります",
"invalid_file_name": "ファイル名に無効な文字が含まれています。ファイル名を変更してもう一度お試しください。",
"no_valid_file_types_selected": "有効なファイルタイプが選択されていません。有効なファイルタイプを選択してください。",
"only_one_file_can_be_uploaded_at_a_time": "一度にアップロードできるファイルは1つだけです。",
"placeholder_text": "クリックまたはドラッグしてファイルをアップロード",
"upload_failed": "アップロードに失敗しました!もう一度お試しください。",
"upload_service_unavailable": "ファイルアップロードサービスは利用できません。後でもう一度試すか、アンケートの所有者に連絡してください。",
"uploading": "アップロード中...",
"you_can_only_upload_a_maximum_of_files": "アップロードできるファイルは最大{FILE_LIMIT}個までです。"
},
+2
View File
@@ -46,10 +46,12 @@
"duplicate_files": "De volgende bestanden zijn al geüpload: {duplicateNames}. Dubbele bestanden zijn niet toegestaan.",
"file_size_exceeded": "De volgende bestanden overschrijden de maximale grootte van {maxSizeInMB} MB en zijn verwijderd: {fileNames}",
"file_size_exceeded_alert": "Het bestand moet kleiner zijn dan {maxSizeInMB} MB",
"invalid_file_name": "De bestandsnaam bevat ongeldige tekens. Wijzig de bestandsnaam en probeer het opnieuw.",
"no_valid_file_types_selected": "Geen geldige bestandstypen geselecteerd. Selecteer een geldig bestandstype.",
"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": "De service voor bestandsuploads is niet beschikbaar. Probeer het later opnieuw of neem contact op met de eigenaar van de enquête.",
"uploading": "Uploaden...",
"you_can_only_upload_a_maximum_of_files": "Je kunt maximaal {FILE_LIMIT} bestanden uploaden."
},
+2
View File
@@ -46,10 +46,12 @@
"duplicate_files": "Os seguintes arquivos já foram carregados: {duplicateNames}. Arquivos duplicados não são permitidos.",
"file_size_exceeded": "Os seguintes arquivos excedem o tamanho máximo de {maxSizeInMB} MB e foram removidos: {fileNames}",
"file_size_exceeded_alert": "O arquivo deve ter menos de {maxSizeInMB} MB",
"invalid_file_name": "O nome do ficheiro contém caracteres inválidos. Altere o nome do ficheiro e tente novamente.",
"no_valid_file_types_selected": "Nenhum tipo de arquivo válido selecionado. Por favor, selecione um tipo de arquivo válido.",
"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": "O serviço de carregamento de ficheiros está indisponível. Tente novamente mais tarde ou contacte o proprietário do inquérito.",
"uploading": "A enviar...",
"you_can_only_upload_a_maximum_of_files": "Você só pode carregar um máximo de {FILE_LIMIT} arquivos."
},
+2
View File
@@ -46,10 +46,12 @@
"duplicate_files": "Următoarele fișiere sunt deja încărcate: {duplicateNames}. Fișierele duplicate nu sunt permise.",
"file_size_exceeded": "Următoarele fișiere depășesc dimensiunea maximă de {maxSizeInMB} MB și au fost eliminate: {fileNames}",
"file_size_exceeded_alert": "Fișierul trebuie să fie mai mic de {maxSizeInMB} MB",
"invalid_file_name": "Numele fișierului conține caractere nevalide. Redenumește fișierul și încearcă din nou.",
"no_valid_file_types_selected": "Nu au fost selectate tipuri de fișiere valide. Te rugăm să selectezi un tip de fișier valid.",
"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": "Serviciul de încărcare a fișierelor nu este disponibil. Încearcă din nou mai târziu sau contactează proprietarul sondajului.",
"uploading": "Se încarcă...",
"you_can_only_upload_a_maximum_of_files": "Poți încărca un număr maxim de {FILE_LIMIT} fișiere."
},
+2
View File
@@ -46,10 +46,12 @@
"duplicate_files": "Следующие файлы уже загружены: {duplicateNames}. Дублирующие файлы не допускаются.",
"file_size_exceeded": "Следующие файлы превышают максимальный размер {maxSizeInMB} МБ и были удалены: {fileNames}",
"file_size_exceeded_alert": "Файл должен быть меньше {maxSizeInMB} МБ",
"invalid_file_name": "Имя файла содержит недопустимые символы. Переименуйте файл и попробуйте снова.",
"no_valid_file_types_selected": "Не выбраны допустимые типы файлов. Пожалуйста, выберите допустимый тип файла.",
"only_one_file_can_be_uploaded_at_a_time": "Можно загрузить только один файл за раз.",
"placeholder_text": "Нажмите или перетащите файлы для загрузки",
"upload_failed": "Ошибка загрузки! Пожалуйста, попробуйте снова.",
"upload_service_unavailable": "Сервис загрузки файлов недоступен. Повторите попытку позже или свяжитесь с владельцем опроса.",
"uploading": "Загрузка...",
"you_can_only_upload_a_maximum_of_files": "Вы можете загрузить максимум {FILE_LIMIT} файлов."
},
+2
View File
@@ -46,10 +46,12 @@
"duplicate_files": "Följande filer är redan uppladdade: {duplicateNames}. Dubbletter av filer är inte tillåtna.",
"file_size_exceeded": "Följande filer överstiger maxstorleken på {maxSizeInMB} MB och togs bort: {fileNames}",
"file_size_exceeded_alert": "Filen måste vara mindre än {maxSizeInMB} MB",
"invalid_file_name": "Filnamnet innehåller ogiltiga tecken. Byt namn på filen och försök igen.",
"no_valid_file_types_selected": "Inga giltiga filtyper valda. Vänligen välj en giltig filtyp.",
"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": "Filuppladdningstjänsten är inte tillgänglig. Försök igen senare eller kontakta enkätens ägare.",
"uploading": "Laddar upp...",
"you_can_only_upload_a_maximum_of_files": "Du kan ladda upp maximalt {FILE_LIMIT} filer."
},
+2
View File
@@ -46,10 +46,12 @@
"duplicate_files": "Şu dosyalar zaten yüklendi: {duplicateNames}. Yinelenen dosyalara izin verilmez.",
"file_size_exceeded": "Şu dosya(lar) maksimum {maxSizeInMB} MB boyutunu aşıyor ve kaldırıldı: {fileNames}",
"file_size_exceeded_alert": "Dosya {maxSizeInMB} MB'den küçük olmalıdır",
"invalid_file_name": "Dosya adı geçersiz karakterler içeriyor. Lütfen dosyayı yeniden adlandırıp tekrar deneyin.",
"no_valid_file_types_selected": "Geçerli dosya türü seçilmedi. Lütfen geçerli bir dosya türü seçin.",
"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": "Dosya yükleme hizmeti kullanılamıyor. Lütfen daha sonra tekrar deneyin veya anket sahibiyle iletişime geçin.",
"uploading": "Yükleniyor...",
"you_can_only_upload_a_maximum_of_files": "En fazla {FILE_LIMIT} dosya yükleyebilirsiniz."
},
+2
View File
@@ -46,10 +46,12 @@
"duplicate_files": "Quyidagi fayllar allaqachon yuklangan: {duplicateNames}. Takroriy fayllarga ruxsat berilmaydi.",
"file_size_exceeded": "Quyidagi fayl(lar) {maxSizeInMB} MB maksimal hajmdan oshib ketdi va olib tashlandi: {fileNames}",
"file_size_exceeded_alert": "Fayl hajmi {maxSizeInMB} MB dan kam bo'lishi kerak",
"invalid_file_name": "Fayl nomida yaroqsiz belgilar bor. Fayl nomini ozgartirib, qayta urinib koring.",
"no_valid_file_types_selected": "Hech qanday yaroqli fayl turi tanlanmadi. Iltimos, yaroqli fayl turini tanlang.",
"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": "Fayl yuklash xizmati mavjud emas. Keyinroq qayta urinib koring yoki sorovnoma egasiga murojaat qiling.",
"uploading": "Yuklanmoqda...",
"you_can_only_upload_a_maximum_of_files": "Siz faqat {FILE_LIMIT} ta faylni maksimal yuklashingiz mumkin."
},
+2
View File
@@ -46,10 +46,12 @@
"duplicate_files": "以下文件已上传:{duplicateNames}。不允许重复文件。",
"file_size_exceeded": "以下文件超过了最大大小 {maxSizeInMB} MB,已被移除:{fileNames}",
"file_size_exceeded_alert": "文件应小于 {maxSizeInMB} MB",
"invalid_file_name": "文件名包含无效字符。请重命名文件后重试。",
"no_valid_file_types_selected": "未选择有效的文件类型。请选择有效的文件类型。",
"only_one_file_can_be_uploaded_at_a_time": "一次只能上传一个文件。",
"placeholder_text": "点击或拖拽上传文件",
"upload_failed": "上传失败!请重试。",
"upload_service_unavailable": "文件上传服务不可用。请稍后重试,或联系调查问卷所有者。",
"uploading": "上传中...",
"you_can_only_upload_a_maximum_of_files": "您最多只能上传 {FILE_LIMIT} 个文件。"
},
@@ -1,6 +1,7 @@
import { useCallback, useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { FileUpload, type UploadedFile } from "@formbricks/survey-ui";
import { FILE_UPLOAD_ERROR_NAMES } from "@formbricks/types/errors";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TAllowedFileExtension } from "@formbricks/types/storage";
import type { TSurveyFileUploadElement } from "@formbricks/types/surveys/elements";
@@ -284,10 +285,15 @@ export function FileUploadElement({
setFileErrorMessage(undefined);
} catch (err: any) {
// Handle upload errors
if (err?.name === "FileTooLargeError") {
if (err?.name === FILE_UPLOAD_ERROR_NAMES.FILE_TOO_LARGE) {
setFileErrorMessage(err.message);
} else if (err?.name === "InvalidFileNameError") {
setFileErrorMessage(t("errors.file_input.upload_failed"));
} else if (err?.name === FILE_UPLOAD_ERROR_NAMES.INVALID_FILE_NAME) {
setFileErrorMessage(t("errors.file_input.invalid_file_name"));
} else if (
err?.name === FILE_UPLOAD_ERROR_NAMES.STORAGE_NOT_CONFIGURED ||
err?.name === FILE_UPLOAD_ERROR_NAMES.STORAGE_UPLOAD_FAILED
) {
setFileErrorMessage(t("errors.file_input.upload_service_unavailable"));
} else {
setFileErrorMessage(t("errors.file_input.upload_failed"));
}
@@ -1,4 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { FILE_UPLOAD_ERROR_NAMES } from "@formbricks/types/errors";
import { STORAGE_ERROR_CODES } from "@formbricks/types/storage";
import { ApiClient } from "./api-client";
describe("ApiClient", () => {
@@ -202,6 +204,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: STORAGE_ERROR_CODES.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: FILE_UPLOAD_ERROR_NAMES.STORAGE_NOT_CONFIGURED });
});
test("throws an error if actual upload fails", async () => {
vi.mocked(global.fetch)
.mockResolvedValueOnce({
@@ -230,6 +252,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: FILE_UPLOAD_ERROR_NAMES.STORAGE_UPLOAD_FAILED });
});
test('throws "Error uploading file" if base64 is invalid', async () => {
// Mock the initial "signing" fetch to succeed
vi.mocked(global.fetch).mockResolvedValueOnce({
+42 -12
View File
@@ -1,9 +1,14 @@
import { TDisplayCreateInput } from "@formbricks/types/displays";
import { Result } from "@formbricks/types/error-handlers";
import { ApiErrorResponse } from "@formbricks/types/errors";
import { type ApiErrorResponse, FILE_UPLOAD_ERROR_NAMES } from "@formbricks/types/errors";
import { TSurveyQuotaAction } from "@formbricks/types/quota";
import { TResponseInput, TResponseUpdateInput } from "@formbricks/types/responses";
import { TUploadFileConfig, TUploadFileResponse } from "@formbricks/types/storage";
import {
STORAGE_CONFIGURATION_ERROR_CODES,
type TStorageApiErrorDetails,
type TUploadFileConfig,
type TUploadFileResponse,
} from "@formbricks/types/storage";
import { makeRequest } from "@/lib/utils";
type TResponseCreateResponseQuotaFull = {
@@ -23,6 +28,18 @@ type TResponseCreateResponse = {
type TResponseUpdateResponse = Record<string, unknown> & TResponseQuota;
type TUploadApiErrorResponse = ApiErrorResponse & {
details?: ApiErrorResponse["details"] & TStorageApiErrorDetails;
};
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 = FILE_UPLOAD_ERROR_NAMES.INVALID_FILE_NAME;
throw err;
}
if (
response.status >= 500 &&
json?.details?.storage_error_code &&
STORAGE_CONFIGURATION_ERROR_CODES.has(json.details.storage_error_code)
) {
const err = new Error("File upload service is not configured");
err.name = FILE_UPLOAD_ERROR_NAMES.STORAGE_NOT_CONFIGURED;
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 = FILE_UPLOAD_ERROR_NAMES.STORAGE_UPLOAD_FAILED;
throw error;
}
if (!uploadResponse.ok) {
@@ -181,11 +209,13 @@ export class ApiClient {
if (presignedFields && errorText.includes("EntityTooLarge")) {
const error = new Error("File size exceeds the size limit for your plan");
error.name = "FileTooLargeError";
error.name = FILE_UPLOAD_ERROR_NAMES.FILE_TOO_LARGE;
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 = FILE_UPLOAD_ERROR_NAMES.STORAGE_UPLOAD_FAILED;
throw error;
}
return fileUrl;
+7
View File
@@ -147,6 +147,13 @@ export {
};
export type { NetworkError, ForbiddenError };
export const FILE_UPLOAD_ERROR_NAMES = {
INVALID_FILE_NAME: "InvalidFileNameError",
STORAGE_NOT_CONFIGURED: "StorageNotConfiguredError",
STORAGE_UPLOAD_FAILED: "StorageUploadFailedError",
FILE_TOO_LARGE: "FileTooLargeError",
} as const;
/**
* Error names that represent expected business-logic failures.
* These are handled gracefully in the UI and should NOT be reported to Sentry.
+17
View File
@@ -1,6 +1,23 @@
import { z } from "zod";
import { ZStorageUrl } from "./common";
export const STORAGE_ERROR_CODES = {
S3_CREDENTIALS_ERROR: "s3_credentials_error",
S3_CLIENT_ERROR: "s3_client_error",
} as const;
export const STORAGE_CONFIGURATION_ERROR_CODES = new Set<string>([
STORAGE_ERROR_CODES.S3_CREDENTIALS_ERROR,
STORAGE_ERROR_CODES.S3_CLIENT_ERROR,
]);
export type TStorageConfigurationErrorCode = (typeof STORAGE_ERROR_CODES)[keyof typeof STORAGE_ERROR_CODES];
export interface TStorageApiErrorDetails {
fileName?: string;
storage_error_code?: string;
}
// Single source of truth for allowed file extensions
const ALLOWED_FILE_EXTENSIONS_TUPLE = [
"heic",
-2
View File
@@ -289,8 +289,6 @@
"RECAPTCHA_SECRET_KEY",
"TELEMETRY_DISABLED",
"TERMS_URL",
"VERCEL",
"VERCEL_URL",
"VERSION",
"WEBAPP_URL",
"UNSPLASH_ACCESS_KEY",