feat: s3 compatible storage (#6536)

Co-authored-by: Victor Santos <victor@formbricks.com>
This commit is contained in:
Anshuman Pandey
2025-09-12 13:47:33 +05:30
committed by GitHub
parent 21c8b5d6e4
commit 96031822a6
220 changed files with 8381 additions and 4831 deletions
+7 -2
View File
@@ -1,6 +1,11 @@
---
description: It should be used **only when the agent explicitly requests database schema-level, details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models, investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
alwaysApply: false
description: >
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
and data patterns. It should be used **only when the agent explicitly requests database schema-level
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
globs: []
alwaysApply: agent-requested
---
# Formbricks Database Schema Reference
-74
View File
@@ -1,74 +0,0 @@
---
alwaysApply: true
---
### Formbricks Monorepo Overview
- **Project**: Formbricks — opensource survey and experience management platform. Repo: [formbricks/formbricks](https://github.com/formbricks/formbricks)
- **Monorepo**: Turborepo + pnpm workspaces. Root configs: [package.json](mdc:package.json), [turbo.json](mdc:turbo.json)
- **Core app**: Next.js app in `apps/web` with Prisma, Auth.js, TailwindCSS, Vitest, Playwright. Enterprise modules live in [apps/web/modules/ee](mdc:apps/web/modules/ee)
- **Datastores**: PostgreSQL + Redis. Local dev via [docker-compose.dev.yml](mdc:docker-compose.dev.yml); Prisma schema at [packages/database/schema.prisma](mdc:packages/database/schema.prisma)
- **Docs & Ops**: Docs in `docs/` (Mintlify), Helm in `helm-chart/`, IaC in `infra/`
### Apps
- **apps/web**: Next.js product application (API, UI, SSO, i18n, emails, uploads, integrations)
- **apps/storybook**: Storybook for UI components; a11y addon + Vite builder
### Packages
- **@formbricks/database** (`packages/database`): Prisma schema, DB scripts, migrations, data layer
- **@formbricks/js-core** (`packages/js-core`): Core runtime for web embed / async loader
- **@formbricks/surveys** (`packages/surveys`): Embeddable survey rendering and helpers
- **@formbricks/logger** (`packages/logger`): Shared logging (pino) + Zod types
- **@formbricks/types** (`packages/types`): Shared types (Zod, Prisma clients)
- **@formbricks/i18n-utils** (`packages/i18n-utils`): i18n helpers and build output
- **@formbricks/eslint-config** (`packages/config-eslint`): Central ESLint config (Next, TS, Vitest, Prettier)
- **@formbricks/config-typescript** (`packages/config-typescript`): Central TS config and types
- **@formbricks/vite-plugins** (`packages/vite-plugins`): Internal Vite plugins
- **packages/android, packages/ios**: Native SDKs (built with platform toolchains)
### Enterpriseready by design
- **Quality & safety**: Strict TypeScript, repowide ESLint + Prettier, lintstaged + Husky, CI checks, typed env validation
- **Securityfirst**: Auth.js, SSO/SAML/OIDC, session controls, rate limiting, Sentry, structured logging
### Accessible by design
- **UI foundations**: Radix UI, TailwindCSS, Storybook with `@storybook/addon-a11y`, keyboard and screenreaderfriendly components
### Root pnpm commands
```bash
pnpm clean:all # Clean turbo cache, node_modules, lockfile, coverage, out
pnpm clean # Clean turbo cache, node_modules, coverage, out
pnpm build # Build all packages/apps (turbo)
pnpm build:dev # Dev-optimized builds (where supported)
pnpm dev # Run all dev servers in parallel
pnpm start # Start built apps/services
pnpm go # Start DB (docker compose) and run long-running dev tasks
pnpm generate # Run generators (e.g., Prisma, API specs)
pnpm lint # Lint all
pnpm format # Prettier write across repo
pnpm test # Unit tests
pnpm test:coverage # Unit tests with coverage
pnpm test:e2e # Playwright tests
pnpm test-e2e:azure # Playwright tests with Azure config
pnpm storybook # Run Storybook
pnpm db:up # Start local Postgres/Redis via docker compose
pnpm db:down # Stop local DB stack
pnpm db:start # Project-level DB setup choreography
pnpm db:push # Prisma db push (accept data loss in package script)
pnpm db:migrate:dev # Apply dev migrations
pnpm db:migrate:deploy # Apply prod migrations
pnpm fb-migrate-dev # Create DB migration (database package) and prisma generate
pnpm tolgee-pull # Pull translation keys for current branch and format
```
### Essentials for every prompt
- **Tech stack**: Next.js, React 19, TypeScript, Prisma, Zod, TailwindCSS, Turborepo, Vitest, Playwright
- **Environments**: See `.env.example`. Many tasks require DB up and env variables set
- **Licensing**: Core under AGPLv3; Enterprise code in `apps/web/modules/ee` (included in Docker, unlocked via Enterprise License Key)
For deeper details, consult perpackage `package.json` and scripts (e.g., [apps/web/package.json](mdc:apps/web/package.json)).
-3
View File
@@ -62,9 +62,6 @@ SMTP_PASSWORD=smtpPassword
# Uncomment the variables you would like to use and customize the values.
# Custom local storage path for file uploads
#UPLOADS_DIR=
##############
# S3 STORAGE #
##############
+57
View File
@@ -55,6 +55,18 @@ jobs:
--health-interval=10s
--health-timeout=5s
--health-retries=5
minio:
image: bitnami/minio:2025.7.23-debian-12-r5
env:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- 9000:9000
options: >-
--health-cmd="curl -fsS http://localhost:9000/minio/health/live || exit 1"
--health-interval=10s
--health-timeout=5s
--health-retries=20
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
@@ -92,8 +104,53 @@ jobs:
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
echo "" >> .env
echo "E2E_TESTING=1" >> .env
echo "S3_REGION=us-east-1" >> .env
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
echo "S3_ACCESS_KEY=minioadmin" >> .env
echo "S3_SECRET_KEY=minioadmin" >> .env
echo "S3_FORCE_PATH_STYLE=1" >> .env
shell: bash
- name: Install MinIO client (mc)
run: |
set -euo pipefail
MC_VERSION="RELEASE.2025-08-13T08-35-41Z"
MC_BASE="https://dl.min.io/client/mc/release/linux-amd64/archive"
MC_BIN="mc.${MC_VERSION}"
MC_SUM="${MC_BIN}.sha256sum"
curl -fsSL "${MC_BASE}/${MC_BIN}" -o "${MC_BIN}"
curl -fsSL "${MC_BASE}/${MC_SUM}" -o "${MC_SUM}"
sha256sum -c "${MC_SUM}"
chmod +x "${MC_BIN}"
sudo mv "${MC_BIN}" /usr/local/bin/mc
- name: Wait for MinIO and create S3 bucket
run: |
set -euo pipefail
echo "Waiting for MinIO to be ready..."
ready=0
for i in {1..60}; do
if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then
echo "MinIO is up after ${i} seconds"
ready=1
break
fi
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "::error::MinIO did not become ready within 60 seconds"
exit 1
fi
mc alias set local http://localhost:9000 minioadmin minioadmin
mc mb --ignore-existing local/formbricks-e2e
- name: Build App
run: |
pnpm build --filter=@formbricks/web...
@@ -17,6 +17,7 @@ import Page from "./page";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
IS_STORAGE_CONFIGURED: true,
FB_LOGO_URL: "https://example.com/mock-logo.png",
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
@@ -82,6 +83,15 @@ vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(() => <div>IdBadge</div>),
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children, variant }: any) => (
<div data-testid="alert" data-variant={variant}>
{children}
</div>
),
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
}));
describe("Page", () => {
afterEach(() => {
cleanup();
@@ -144,6 +154,7 @@ describe("Page", () => {
isFormbricksCloud: IS_FORMBRICKS_CLOUD,
fbLogoUrl: FB_LOGO_URL,
user: mockUser,
isStorageConfigured: true,
},
undefined
);
@@ -277,4 +288,118 @@ describe("Page", () => {
await expect(Page(props)).rejects.toThrow("Authentication error");
});
test("does not show storage warning when IS_STORAGE_CONFIGURED is true", async () => {
const props = {
params: Promise.resolve(mockParams),
};
const PageComponent = await Page(props);
render(PageComponent);
expect(screen.queryByTestId("alert")).not.toBeInTheDocument();
});
test("shows storage warning when IS_STORAGE_CONFIGURED is false", async () => {
// Mock IS_STORAGE_CONFIGURED as false
vi.doMock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
IS_STORAGE_CONFIGURED: false,
FB_LOGO_URL: "https://example.com/mock-logo.png",
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "mock-github-secret",
GOOGLE_CLIENT_ID: "mock-google-client-id",
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_ISSUER: "mock-oidc-issuer",
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
SAML_DATABASE_URL: "mock-saml-database-url",
WEBAPP_URL: "mock-webapp-url",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
}));
// Re-import the module to get the updated mock
const { default: PageWithStorageDisabled } = await import("./page");
const props = {
params: Promise.resolve(mockParams),
};
const PageComponent = await PageWithStorageDisabled(props);
render(PageComponent);
expect(screen.getByTestId("alert")).toBeInTheDocument();
expect(screen.getByTestId("alert")).toHaveAttribute("data-variant", "warning");
expect(screen.getByTestId("alert-description")).toHaveTextContent("common.storage_not_configured");
});
test("passes isStorageConfigured=true to EmailCustomizationSettings when storage is configured", async () => {
const props = {
params: Promise.resolve(mockParams),
};
const PageComponent = await Page(props);
render(PageComponent);
expect(EmailCustomizationSettings).toHaveBeenCalledWith(
expect.objectContaining({
isStorageConfigured: true,
}),
undefined
);
});
test("passes isStorageConfigured=false to EmailCustomizationSettings when storage is not configured", async () => {
// Mock IS_STORAGE_CONFIGURED as false
vi.doMock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
IS_STORAGE_CONFIGURED: false,
FB_LOGO_URL: "https://example.com/mock-logo.png",
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "mock-github-secret",
GOOGLE_CLIENT_ID: "mock-google-client-id",
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_ISSUER: "mock-oidc-issuer",
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
SAML_DATABASE_URL: "mock-saml-database-url",
WEBAPP_URL: "mock-webapp-url",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
}));
// Re-import the module to get the updated mock
const { default: PageWithStorageDisabled } = await import("./page");
const props = {
params: Promise.resolve(mockParams),
};
const PageComponent = await PageWithStorageDisabled(props);
render(PageComponent);
expect(EmailCustomizationSettings).toHaveBeenCalledWith(
expect.objectContaining({
isStorageConfigured: false,
}),
undefined
);
});
});
@@ -1,9 +1,10 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -40,6 +41,13 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
activeId="general"
/>
</PageHeader>
{!IS_STORAGE_CONFIGURED && (
<div className="max-w-4xl">
<Alert variant="warning">
<AlertDescription>{t("common.storage_not_configured")}</AlertDescription>
</Alert>
</div>
)}
<SettingsCard
title={t("environments.settings.general.organization_name")}
description={t("environments.settings.general.organization_name_description")}>
@@ -57,6 +65,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
fbLogoUrl={FB_LOGO_URL}
user={user}
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
{isMultiOrgEnabled && (
<SettingsCard
@@ -11,6 +11,8 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUserLocale } from "@formbricks/types/user";
vi.mock("@sentry/nextjs", () => ({ captureException: vi.fn() }));
// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
default: {
@@ -158,6 +160,11 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", (
getResponsesDownloadUrlAction: vi.fn(),
}));
// Mock handleFileUpload
vi.mock("@/modules/storage/file-upload", () => ({
handleFileUpload: vi.fn(),
}));
vi.mock("@/modules/analysis/components/SingleResponseCard/actions", () => ({
deleteResponseAction: vi.fn(),
}));
@@ -192,6 +199,9 @@ vi.mock("@tolgee/react", () => ({
}),
}));
// Global mock anchor for tests
let globalMockAnchor: any;
// Define mock data for tests
const mockProps = {
data: [
@@ -232,23 +242,94 @@ beforeEach(() => {
// Reset all toast mocks before each test
vi.mocked(toast.error).mockClear();
vi.mocked(toast.success).mockClear();
vi.mocked(getResponsesDownloadUrlAction).mockClear();
// Create a mock anchor element for download tests
const mockAnchor = {
globalMockAnchor = {
href: "",
click: vi.fn(),
style: {},
download: "",
};
// Override the href setter to capture when it's set
Object.defineProperty(globalMockAnchor, "href", {
get() {
return this._href || "";
},
set(value) {
this._href = value;
},
});
// Update how we mock the document methods to avoid infinite recursion
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
if (tagName === "a") return mockAnchor as any;
if (tagName === "a") return globalMockAnchor as any;
return originalCreateElement(tagName);
});
vi.spyOn(document.body, "appendChild").mockReturnValue(null as any);
vi.spyOn(document.body, "removeChild").mockReturnValue(null as any);
// Mock File constructor to avoid arrayBuffer issues
vi.stubGlobal(
"File",
class MockFile {
name: string;
type: string;
size: number;
constructor(_chunks: any[], name: string, options: any = {}) {
this.name = name;
this.type = options.type || "";
this.size = options.size || 0;
}
arrayBuffer() {
return Promise.resolve(new ArrayBuffer(0));
}
} as any
);
// Mock atob for base64 decoding
vi.stubGlobal(
"atob",
vi.fn((_str: string) => "decoded binary string")
);
// Mock Uint8Array and Blob
vi.stubGlobal(
"Uint8Array",
class MockUint8Array extends Array {
constructor(data: any) {
super();
this.length = typeof data === "number" ? data : 0;
}
static from(source: any) {
return new MockUint8Array(source.length || 0);
}
} as any
);
vi.stubGlobal(
"Blob",
class MockBlob {
size: number;
type: string;
constructor(_parts: any[], options: any = {}) {
this.size = 0;
this.type = options.type || "";
}
} as any
);
vi.stubGlobal("URL", {
createObjectURL: vi.fn(),
revokeObjectURL: vi.fn(),
});
});
// Cleanup after each test
@@ -259,6 +340,7 @@ afterEach(() => {
}
cleanup();
vi.restoreAllMocks(); // Restore mocks after each test
vi.unstubAllGlobals(); // Restore global stubs after each test
});
describe("ResponseTable", () => {
@@ -313,52 +395,64 @@ describe("ResponseTable", () => {
test("calls downloadSelectedRows with csv format when toolbar button is clicked", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: "https://download.url/file.csv",
data: {
fileContents: "mock,csv,content",
fileName: "survey-responses.csv",
},
});
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
// Ensure URL.createObjectURL returns a deterministic URL for assertions
(URL.createObjectURL as any).mockReturnValueOnce("https://download.url/file.csv");
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey1",
format: "csv",
filterCriteria: { responseIds: [] },
});
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey1",
format: "csv",
filterCriteria: { responseIds: [] },
});
// Check if link was created and clicked
expect(document.createElement).toHaveBeenCalledWith("a");
const mockLink = document.createElement("a");
expect(mockLink.href).toBe("https://download.url/file.csv");
expect(document.body.appendChild).toHaveBeenCalled();
expect(mockLink.click).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
// Check if link was created and clicked
expect(document.createElement).toHaveBeenCalledWith("a");
expect(globalMockAnchor.href).toBe("https://download.url/file.csv");
expect(document.body.appendChild).toHaveBeenCalled();
expect(globalMockAnchor.click).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
});
});
test("calls downloadSelectedRows with xlsx format when toolbar button is clicked", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: "https://download.url/file.xlsx",
data: {
fileContents: "bW9jayB4bHN4IGNvbnRlbnQ=", // base64 encoded mock data
fileName: "survey-responses.xlsx",
},
});
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
// Ensure URL.createObjectURL returns a deterministic URL for assertions
(URL.createObjectURL as any).mockReturnValueOnce("https://download.url/file.xlsx");
const downloadXlsxButton = screen.getByTestId("download-xlsx");
await userEvent.click(downloadXlsxButton);
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey1",
format: "xlsx",
filterCriteria: { responseIds: [] },
});
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey1",
format: "xlsx",
filterCriteria: { responseIds: [] },
});
// Check if link was created and clicked
expect(document.createElement).toHaveBeenCalledWith("a");
const mockLink = document.createElement("a");
expect(mockLink.href).toBe("https://download.url/file.xlsx");
expect(document.body.appendChild).toHaveBeenCalled();
expect(mockLink.click).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
// Check if link was created and clicked
expect(document.createElement).toHaveBeenCalledWith("a");
expect(globalMockAnchor.href).toBe("https://download.url/file.xlsx");
expect(document.body.appendChild).toHaveBeenCalled();
expect(globalMockAnchor.click).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
});
});
// Test response modal
@@ -4,6 +4,7 @@ import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surv
import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell";
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
import { Button } from "@/modules/ui/components/button";
import {
@@ -112,6 +113,7 @@ export const ResponseTable = ({
() => (isFetchingFirstPage ? Array(10).fill({}) : data),
[data, isFetchingFirstPage]
);
const tableColumns = useMemo(
() =>
isFetchingFirstPage
@@ -198,13 +200,7 @@ export const ResponseTable = ({
});
if (downloadResponse?.data) {
const link = document.createElement("a");
link.href = downloadResponse.data;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
downloadResponsesFile(downloadResponse.data.fileName, downloadResponse.data.fileContents, format);
} else {
toast.error(t("environments.surveys.responses.error_downloading_responses"));
}
@@ -45,6 +45,7 @@ vi.mock(
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_STORAGE_CONFIGURED: true,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
@@ -232,6 +233,7 @@ describe("ResponsesPage", () => {
publicDomain: mockPublicDomain,
responseCount: 10,
displayCount: 5,
isStorageConfigured: true,
}),
undefined
);
@@ -1,7 +1,7 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
@@ -78,6 +78,7 @@ const Page = async (props) => {
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
}>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
@@ -1,8 +1,6 @@
"use server";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { WEBAPP_URL } from "@/lib/constants";
import { putFile } from "@/lib/storage/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -218,16 +216,9 @@ export const generatePersonalLinksAction = authenticatedActionClient
const csvContent = await convertToCsv(csvHeaders, csvData);
const fileName = `personal-links-${parsedInput.surveyId}-${Date.now()}.csv`;
// Store file temporarily and return download URL
const fileBuffer = Buffer.from(csvContent);
await putFile(fileName, fileBuffer, "private", parsedInput.environmentId);
const downloadUrl = `${WEBAPP_URL}/storage/${parsedInput.environmentId}/private/${fileName}`;
return {
downloadUrl,
fileName,
count: csvData.length,
csvContent,
};
});
@@ -19,7 +19,7 @@ vi.mock("./QuestionSummaryHeader", () => ({
}));
// Mock utility functions
vi.mock("@/lib/storage/utils", () => ({
vi.mock("@/modules/storage/utils", () => ({
getOriginalFileNameFromUrl: (url: string) => `original-${url.split("/").pop()}`,
}));
@@ -1,8 +1,8 @@
"use client";
import { getOriginalFileNameFromUrl } from "@/lib/storage/utils";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
@@ -282,7 +282,7 @@ const mockSurvey: TSurvey = {
recaptcha: null,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
};
} as unknown as TSurvey;
const mockUser: TUser = {
id: "test-user-id",
@@ -320,6 +320,7 @@ const defaultProps = {
segments: mockSegments,
isContactsEnabled: true,
isFormbricksCloud: false,
isStorageConfigured: true,
};
describe("SurveyAnalysisCTA", () => {
@@ -33,6 +33,7 @@ interface SurveyAnalysisCTAProps {
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
isStorageConfigured: boolean;
}
interface ModalState {
@@ -51,6 +52,7 @@ export const SurveyAnalysisCTA = ({
segments,
isContactsEnabled,
isFormbricksCloud,
isStorageConfigured,
}: SurveyAnalysisCTAProps) => {
const { t } = useTranslate();
const router = useRouter();
@@ -213,6 +215,7 @@ export const SurveyAnalysisCTA = ({
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
isReadOnly={isReadOnly}
isStorageConfigured={isStorageConfigured}
/>
)}
<SuccessMessage environment={environment} survey={survey} />
@@ -279,6 +279,7 @@ const defaultProps = {
isContactsEnabled: true,
isFormbricksCloud: false,
isReadOnly: false,
isStorageConfigured: true,
};
describe("ShareSurveyModal", () => {
@@ -49,6 +49,7 @@ interface ShareSurveyModalProps {
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
isReadOnly: boolean;
isStorageConfigured: boolean;
}
export const ShareSurveyModal = ({
@@ -62,6 +63,7 @@ export const ShareSurveyModal = ({
isContactsEnabled,
isFormbricksCloud,
isReadOnly,
isStorageConfigured,
}: ShareSurveyModalProps) => {
const environmentId = survey.environmentId;
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
@@ -176,7 +178,7 @@ export const ShareSurveyModal = ({
title: t("environments.surveys.share.link_settings.title"),
description: t("environments.surveys.share.link_settings.description"),
componentType: LinkSettingsTab,
componentProps: { isReadOnly, locale: user.locale },
componentProps: { isReadOnly, locale: user.locale, isStorageConfigured },
},
],
[
@@ -191,6 +193,7 @@ export const ShareSurveyModal = ({
isContactsEnabled,
isFormbricksCloud,
email,
isStorageConfigured,
]
);
@@ -148,7 +148,7 @@ describe("LinkSettingsTab", () => {
});
test("renders form fields correctly", () => {
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" isStorageConfigured={true} />);
expect(screen.getByText("common.language")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.share.link_settings.link_title")).toBeInTheDocument();
@@ -158,7 +158,7 @@ describe("LinkSettingsTab", () => {
});
test("initializes form with existing metadata", () => {
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" isStorageConfigured={true} />);
const titleInput = screen.getByDisplayValue("Test Title");
const descriptionInput = screen.getByDisplayValue("Test Description");
@@ -179,7 +179,7 @@ describe("LinkSettingsTab", () => {
vi.mocked(createI18nString).mockReturnValue({ default: "", en: "" });
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" isStorageConfigured={true} />);
expect(vi.mocked(createI18nString)).toHaveBeenCalledWith("", ["default", "en"]);
});
@@ -193,19 +193,19 @@ describe("LinkSettingsTab", () => {
{ language: { id: "lang1", code: "default" }, default: true, enabled: true } as TSurveyLanguage,
]);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" isStorageConfigured={true} />);
expect(screen.queryByText("common.language")).not.toBeInTheDocument();
});
test("shows language selector for multi-language surveys", () => {
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" isStorageConfigured={true} />);
expect(screen.getByText("common.language")).toBeInTheDocument();
});
test("handles language change correctly", async () => {
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" isStorageConfigured={true} />);
// Since the Select component is complex to test in JSDOM, let's test that
// the language selector is rendered and has the expected options
@@ -226,7 +226,7 @@ describe("LinkSettingsTab", () => {
test("handles title input change", async () => {
const user = userEvent.setup();
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" isStorageConfigured={true} />);
const titleInput = screen.getByDisplayValue("Test Title");
await user.clear(titleInput);
@@ -237,7 +237,7 @@ describe("LinkSettingsTab", () => {
test("handles description input change", async () => {
const user = userEvent.setup();
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" isStorageConfigured={true} />);
const descriptionInput = screen.getByDisplayValue("Test Description");
await user.clear(descriptionInput);
@@ -247,7 +247,7 @@ describe("LinkSettingsTab", () => {
});
test("handles file upload", async () => {
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" isStorageConfigured={true} />);
const fileInput = screen.getByTestId("file-input");
fireEvent.change(fileInput, { target: { value: "https://example.com/new-image.png" } });
@@ -256,7 +256,7 @@ describe("LinkSettingsTab", () => {
});
test("handles file removal", async () => {
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" isStorageConfigured={true} />);
const fileInput = screen.getByTestId("file-input");
fireEvent.change(fileInput, { target: { value: "" } });
@@ -265,7 +265,7 @@ describe("LinkSettingsTab", () => {
});
test("disables form when isReadOnly is true", () => {
render(<LinkSettingsTab isReadOnly={true} locale="en-US" />);
render(<LinkSettingsTab isReadOnly={true} locale="en-US" isStorageConfigured={true} />);
const titleInput = screen.getByDisplayValue("Test Title");
const descriptionInput = screen.getByDisplayValue("Test Description");
@@ -278,7 +278,7 @@ describe("LinkSettingsTab", () => {
test("submits form successfully", async () => {
const user = userEvent.setup();
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" isStorageConfigured={true} />);
const titleInput = screen.getByDisplayValue("Test Title");
await user.clear(titleInput);
@@ -299,7 +299,7 @@ describe("LinkSettingsTab", () => {
const user = userEvent.setup();
vi.mocked(updateSurveyAction).mockResolvedValue({ data: mockSurvey });
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" isStorageConfigured={true} />);
const titleInput = screen.getByDisplayValue("Test Title");
await user.clear(titleInput);
@@ -321,7 +321,7 @@ describe("LinkSettingsTab", () => {
});
vi.mocked(updateSurveyAction).mockReturnValue(pendingPromise as any);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" isStorageConfigured={true} />);
// Make form dirty first
const titleInput = screen.getByDisplayValue("Test Title");
@@ -348,7 +348,7 @@ describe("LinkSettingsTab", () => {
test("does not submit when isReadOnly is true", async () => {
const user = userEvent.setup();
render(<LinkSettingsTab isReadOnly={true} locale="en-US" />);
render(<LinkSettingsTab isReadOnly={true} locale="en-US" isStorageConfigured={true} />);
const saveButton = screen.getByTestId("save-button");
await user.click(saveButton);
@@ -358,7 +358,7 @@ describe("LinkSettingsTab", () => {
test("handles ogImage correctly in form submission", async () => {
const user = userEvent.setup();
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" isStorageConfigured={true} />);
const fileInput = screen.getByTestId("file-input");
fireEvent.change(fileInput, { target: { value: "https://example.com/new-image.png" } });
@@ -376,7 +376,7 @@ describe("LinkSettingsTab", () => {
test("handles empty ogImage correctly in form submission", async () => {
const user = userEvent.setup();
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" isStorageConfigured={true} />);
const fileInput = screen.getByTestId("file-input");
fireEvent.change(fileInput, { target: { value: "" } });
@@ -406,7 +406,7 @@ describe("LinkSettingsTab", () => {
survey: surveyWithPartialMetadata,
});
render(<LinkSettingsTab isReadOnly={false} locale="en-US" />);
render(<LinkSettingsTab isReadOnly={false} locale="en-US" isStorageConfigured={true} />);
const titleInput = screen.getByDisplayValue("Existing Title");
await user.clear(titleInput);
@@ -32,6 +32,7 @@ import { TI18nString, TSurvey, TSurveyMetadata } from "@formbricks/types/surveys
interface LinkSettingsTabProps {
isReadOnly: boolean;
locale: string;
isStorageConfigured: boolean;
}
interface LinkSettingsFormData {
@@ -40,7 +41,7 @@ interface LinkSettingsFormData {
ogImage?: string;
}
export const LinkSettingsTab = ({ isReadOnly, locale }: LinkSettingsTabProps) => {
export const LinkSettingsTab = ({ isReadOnly, locale, isStorageConfigured }: LinkSettingsTabProps) => {
const { t } = useTranslate();
const { survey } = useSurvey();
const enabledLanguages = getEnabledLanguages(survey.languages);
@@ -236,6 +237,7 @@ export const LinkSettingsTab = ({ isReadOnly, locale }: LinkSettingsTabProps) =>
fileUrl={field.value}
maxSizeInMB={5}
disabled={isReadOnly}
isStorageConfigured={isStorageConfigured}
/>
</FormControl>
<FormDescription>
@@ -122,7 +122,21 @@ export const PersonalLinksTab = ({
});
if (result?.data) {
downloadFile(result.data.downloadUrl, result.data.fileName || "personal-links.csv");
const fileName = result.data.fileName || "personal-links.csv";
const file = new File([result.data.csvContent], fileName, {
type: "text/csv",
});
try {
const url = URL.createObjectURL(file);
downloadFile(url, fileName);
URL.revokeObjectURL(url);
} catch {
toast.error(t("environments.surveys.share.personal_links.error_generating_links"));
setIsGenerating(false);
return;
}
toast.success(t("environments.surveys.share.personal_links.links_generated_success_toast"), {
duration: 5000,
id: "generating-links",
@@ -134,6 +148,7 @@ export const PersonalLinksTab = ({
id: "generating-links",
});
}
setIsGenerating(false);
};
@@ -22,6 +22,7 @@ import { TUser } from "@formbricks/types/user";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_STORAGE_CONFIGURED: true,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
@@ -2,7 +2,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
@@ -74,6 +74,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
}>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />
@@ -1,7 +1,7 @@
"use server";
import { getOrganization } from "@/lib/organization/service";
import { getResponseDownloadUrl, getResponseFilteringValues } from "@/lib/response/service";
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
@@ -46,7 +46,11 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
],
});
return getResponseDownloadUrl(parsedInput.surveyId, parsedInput.format, parsedInput.filterCriteria);
return await getResponseDownloadFile(
parsedInput.surveyId,
parsedInput.format,
parsedInput.filterCriteria
);
});
const ZGetSurveyFilterDataAction = z.object({
@@ -5,8 +5,8 @@ import {
useResponseFilter,
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
import { Calendar } from "@/modules/ui/components/calendar";
import {
@@ -16,6 +16,7 @@ import {
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { cn } from "@/modules/ui/lib/utils";
import * as Sentry from "@sentry/nextjs";
import { TFnType, useTranslate } from "@tolgee/react";
import {
differenceInDays,
@@ -238,29 +239,32 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
setSelectingDate(DateSelected.FROM);
};
const handleDownloadResponses = async (filter: FilterDownload, filetype: "csv" | "xlsx") => {
const responseFilters = filter === FilterDownload.ALL ? {} : filters;
setIsDownloading(true);
const handleDownloadResponses = async (filter: FilterDownload, fileType: "csv" | "xlsx") => {
try {
const responseFilters = filter === FilterDownload.ALL ? {} : filters;
setIsDownloading(true);
const responsesDownloadUrlResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id,
format: filetype,
filterCriteria: responseFilters,
});
const responsesDownloadUrlResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id,
format: fileType,
filterCriteria: responseFilters,
});
if (responsesDownloadUrlResponse?.data) {
const link = document.createElement("a");
link.href = responsesDownloadUrlResponse.data;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
const errorMessage = getFormattedErrorMessage(responsesDownloadUrlResponse);
toast.error(errorMessage);
if (responsesDownloadUrlResponse?.data) {
downloadResponsesFile(
responsesDownloadUrlResponse.data.fileName,
responsesDownloadUrlResponse.data.fileContents,
fileType
);
} else {
toast.error(t("environments.surveys.responses.error_downloading_responses"));
}
} catch (err) {
Sentry.captureException(err);
toast.error(t("environments.surveys.responses.error_downloading_responses"));
} finally {
setIsDownloading(false);
}
setIsDownloading(false);
};
useClickOutside(datePickerRef, () => handleDatePickerClose());
@@ -0,0 +1,44 @@
export const downloadResponsesFile = (
fileName: string,
fileContents: string,
fileType: "csv" | "xlsx"
): void => {
if (typeof window === "undefined" || typeof document === "undefined") {
throw new Error("downloadResponsesFile can only be used in a browser environment");
}
const trimmedName = (fileName ?? "").trim();
const requiredExt = fileType === "xlsx" ? ".xlsx" : ".csv";
let normalizedFileName = trimmedName || `responses-${new Date().toISOString().slice(0, 10)}${requiredExt}`;
if (!normalizedFileName.toLowerCase().endsWith(requiredExt)) {
normalizedFileName = `${normalizedFileName}${requiredExt}`;
}
let file: File;
if (fileType === "xlsx") {
const binaryString = atob(fileContents);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
file = new File([bytes], normalizedFileName, {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
} else {
file = new File([fileContents], normalizedFileName, {
type: "text/csv;charset=utf-8",
});
}
const link = document.createElement("a");
let url: string | undefined;
url = URL.createObjectURL(file);
link.href = url;
link.download = normalizedFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
@@ -2,11 +2,11 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -2,11 +2,11 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { headers } from "next/headers";
import { NextRequest } from "next/server";
import { UAParser } from "ua-parser-js";
@@ -1,103 +0,0 @@
import { getUploadSignedUrl } from "@/lib/storage/service";
import { afterEach, describe, expect, test, vi } from "vitest";
import { uploadPrivateFile } from "./uploadPrivateFile";
vi.mock("@/lib/storage/service", () => ({
getUploadSignedUrl: vi.fn(),
}));
describe("uploadPrivateFile", () => {
afterEach(() => {
vi.resetAllMocks();
});
test("should return a success response with signed URL details when getUploadSignedUrl successfully generates a signed URL", async () => {
const mockSignedUrlResponse = {
signedUrl: "mocked-signed-url",
presignedFields: { field1: "value1" },
fileUrl: "mocked-file-url",
};
vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse);
const fileName = "test-file.txt";
const environmentId = "test-env-id";
const fileType = "text/plain";
const result = await uploadPrivateFile(fileName, environmentId, fileType);
const resultData = await result.json();
expect(getUploadSignedUrl).toHaveBeenCalledWith(fileName, environmentId, fileType, "private", false);
expect(resultData).toEqual({
data: mockSignedUrlResponse,
});
});
test("should return a success response when isBiggerFileUploadAllowed is true and getUploadSignedUrl successfully generates a signed URL", async () => {
const mockSignedUrlResponse = {
signedUrl: "mocked-signed-url",
presignedFields: { field1: "value1" },
fileUrl: "mocked-file-url",
};
vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse);
const fileName = "test-file.txt";
const environmentId = "test-env-id";
const fileType = "text/plain";
const isBiggerFileUploadAllowed = true;
const result = await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed);
const resultData = await result.json();
expect(getUploadSignedUrl).toHaveBeenCalledWith(
fileName,
environmentId,
fileType,
"private",
isBiggerFileUploadAllowed
);
expect(resultData).toEqual({
data: mockSignedUrlResponse,
});
});
test("should return an internal server error response when getUploadSignedUrl throws an error", async () => {
vi.mocked(getUploadSignedUrl).mockRejectedValue(new Error("S3 unavailable"));
const fileName = "test-file.txt";
const environmentId = "test-env-id";
const fileType = "text/plain";
const result = await uploadPrivateFile(fileName, environmentId, fileType);
expect(result.status).toBe(500);
const resultData = await result.json();
expect(resultData).toEqual({
code: "internal_server_error",
details: {},
message: "Internal server error",
});
});
test("should return an internal server error response when fileName has no extension", async () => {
vi.mocked(getUploadSignedUrl).mockRejectedValue(new Error("File extension not found"));
const fileName = "test-file";
const environmentId = "test-env-id";
const fileType = "text/plain";
const result = await uploadPrivateFile(fileName, environmentId, fileType);
const resultData = await result.json();
expect(getUploadSignedUrl).toHaveBeenCalledWith(fileName, environmentId, fileType, "private", false);
expect(result.status).toBe(500);
expect(resultData).toEqual({
code: "internal_server_error",
details: {},
message: "Internal server error",
});
});
});
@@ -1,28 +0,0 @@
import { responses } from "@/app/lib/api/response";
import { getUploadSignedUrl } from "@/lib/storage/service";
export const uploadPrivateFile = async (
fileName: string,
environmentId: string,
fileType: string,
isBiggerFileUploadAllowed: boolean = false
) => {
const accessType = "private"; // private files are only accessible by the user who has access to the environment
// if s3 is not configured, we'll upload to a local folder named uploads
try {
const signedUrlResponse = await getUploadSignedUrl(
fileName,
environmentId,
fileType,
accessType,
isBiggerFileUploadAllowed
);
return responses.successResponse({
...signedUrlResponse,
});
} catch (err) {
return responses.internalServerErrorResponse("Internal server error");
}
};
@@ -1,178 +0,0 @@
// headers -> "Content-Type" should be present and set to a valid MIME type
// body -> should be a valid file object (buffer)
// method -> PUT (to be the same as the signedUrl method)
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
import { validateLocalSignedUrl } from "@/lib/crypto";
import { validateFile } from "@/lib/fileValidation";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { putFileToLocalStorage } from "@/lib/storage/service";
import { getSurvey } from "@/lib/survey/service";
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
interface Context {
params: Promise<{
environmentId: string;
}>;
}
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
true,
// Cache CORS preflight responses for 1 hour (conservative approach)
// Balances performance gains with flexibility for CORS policy changes
"public, s-maxage=3600, max-age=3600"
);
};
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
if (!ENCRYPTION_KEY) {
return {
response: responses.internalServerErrorResponse("Encryption key is not set"),
};
}
const params = await props.params;
const environmentId = params.environmentId;
const accessType = "private"; // private files are accessible only by authorized users
const jsonInput = await req.json();
const fileType = jsonInput.fileType as string;
const encodedFileName = jsonInput.fileName as string;
const surveyId = jsonInput.surveyId as string;
const signedSignature = jsonInput.signature as string;
const signedUuid = jsonInput.uuid as string;
const signedTimestamp = jsonInput.timestamp as string;
if (!fileType) {
return {
response: responses.badRequestResponse("contentType is required"),
};
}
if (!encodedFileName) {
return {
response: responses.badRequestResponse("fileName is required"),
};
}
if (!surveyId) {
return {
response: responses.badRequestResponse("surveyId is required"),
};
}
if (!signedSignature) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!signedUuid) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!signedTimestamp) {
return {
response: responses.unauthorizedResponse(),
};
}
const [survey, organization] = await Promise.all([
getSurvey(surveyId),
getOrganizationByEnvironmentId(environmentId),
]);
if (!survey) {
return {
response: responses.notFoundResponse("Survey", surveyId),
};
}
if (!organization) {
return {
response: responses.notFoundResponse("OrganizationByEnvironmentId", environmentId),
};
}
const fileName = decodeURIComponent(encodedFileName);
// Perform server-side file validation again
// This is crucial as attackers could bypass the initial validation and directly call this endpoint
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return {
response: responses.badRequestResponse(fileValidation.error ?? "Invalid file", {
fileName,
fileType,
}),
};
}
// validate signature
const validated = validateLocalSignedUrl(
signedUuid,
fileName,
environmentId,
fileType,
Number(signedTimestamp),
signedSignature,
ENCRYPTION_KEY
);
if (!validated) {
return {
response: responses.unauthorizedResponse(),
};
}
const base64String = jsonInput.fileBase64String as string;
const buffer = Buffer.from(base64String.split(",")[1], "base64");
const file = new Blob([buffer], { type: fileType });
if (!file) {
return {
response: responses.badRequestResponse("fileBuffer is required"),
};
}
try {
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
const bytes = await file.arrayBuffer();
const fileBuffer = Buffer.from(bytes);
await putFileToLocalStorage(
fileName,
fileBuffer,
accessType,
environmentId,
UPLOADS_DIR,
isBiggerFileUploadAllowed
);
return {
response: responses.successResponse({
message: "File uploaded successfully",
}),
};
} catch (err) {
logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/upload");
if (err.name === "FileTooLargeError") {
return {
response: responses.badRequestResponse(err.message),
};
}
return {
response: responses.internalServerErrorResponse("File upload failed"),
};
}
},
});
@@ -1,13 +1,16 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFile } from "@/lib/fileValidation";
import { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey } from "@/lib/survey/service";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
import { getSignedUrlForUpload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
import { NextRequest } from "next/server";
import { ZUploadFileRequest } from "@formbricks/types/storage";
import { uploadPrivateFile } from "./lib/uploadPrivateFile";
import { logger } from "@formbricks/logger";
import { TUploadPrivateFileRequest, ZUploadPrivateFileRequest } from "@formbricks/types/storage";
interface Context {
params: Promise<{
@@ -25,46 +28,46 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
// api endpoint for uploading private files
// api endpoint for getting a s3 signed url for uploading private files
// uploaded files will be private, only the user who has access to the environment can access the file
// uploading private files requires no authentication
// use this to let users upload files to a survey for example
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
// use this to let users upload files to a file upload question response for example
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
const params = await props.params;
const environmentId = params.environmentId;
const { environmentId } = params;
let jsonInput: TUploadPrivateFileRequest;
const jsonInput = await req.json();
const inputValidation = ZUploadFileRequest.safeParse({
try {
jsonInput = await req.json();
} catch (error) {
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const parsedInputResult = ZUploadPrivateFileRequest.safeParse({
...jsonInput,
environmentId,
});
if (!inputValidation.success) {
if (!parsedInputResult.success) {
const errorDetails = transformErrorToDetails(parsedInputResult.error);
logger.error({ error: errorDetails }, "Fields are missing or incorrectly formatted");
return {
response: responses.badRequestResponse(
"Invalid request",
transformErrorToDetails(inputValidation.error),
"Fields are missing or incorrectly formatted",
errorDetails,
true
),
};
}
const { fileName, fileType, surveyId } = inputValidation.data;
// Perform server-side file validation
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return {
response: responses.badRequestResponse(
fileValidation.error ?? "Invalid file",
{ fileName, fileType },
true
),
};
}
const { fileName, fileType, surveyId } = parsedInputResult.data;
const [survey, organization] = await Promise.all([
getSurvey(surveyId),
@@ -83,10 +86,40 @@ export const POST = withV1ApiWrapper({
};
}
if (survey.environmentId !== environmentId) {
return {
response: responses.badRequestResponse(
"Survey does not belong to the environment",
{ surveyId, environmentId },
true
),
};
}
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
const maxFileUploadSize = isBiggerFileUploadAllowed
? MAX_FILE_UPLOAD_SIZES.big
: MAX_FILE_UPLOAD_SIZES.standard;
const signedUrlResponse = await getSignedUrlForUpload(
fileName,
environmentId,
fileType,
"private",
maxFileUploadSize
);
if (!signedUrlResponse.ok) {
logger.error({ error: signedUrlResponse.error }, "Error getting signed url for upload");
const errorResponse = getErrorResponseFromStorageError(signedUrlResponse.error, { fileName });
return {
response: errorResponse,
};
}
return {
response: await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed),
response: responses.successResponse(signedUrlResponse.data),
};
},
customRateLimitConfig: rateLimitConfigs.storage.upload,
});
@@ -2,10 +2,10 @@ import { handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
@@ -1,9 +1,9 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation";
import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
@@ -1,58 +0,0 @@
import { responses } from "@/app/lib/api/response";
import { getUploadSignedUrl } from "@/lib/storage/service";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { getSignedUrlForPublicFile } from "./getSignedUrl";
vi.mock("@/app/lib/api/response", () => ({
responses: {
successResponse: vi.fn((data) => ({ data })),
internalServerErrorResponse: vi.fn((message) => ({ message })),
},
}));
vi.mock("@/lib/storage/service", () => ({
getUploadSignedUrl: vi.fn(),
}));
describe("getSignedUrlForPublicFile", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("should return success response with signed URL data", async () => {
const mockFileName = "test.jpg";
const mockEnvironmentId = "env123";
const mockFileType = "image/jpeg";
const mockSignedUrlResponse = {
signedUrl: "http://example.com/signed-url",
signingData: { signature: "sig", timestamp: 123, uuid: "uuid" },
updatedFileName: "test--fid--uuid.jpg",
fileUrl: "http://example.com/file-url",
};
vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse);
const result = await getSignedUrlForPublicFile(mockFileName, mockEnvironmentId, mockFileType);
expect(getUploadSignedUrl).toHaveBeenCalledWith(mockFileName, mockEnvironmentId, mockFileType, "public");
expect(responses.successResponse).toHaveBeenCalledWith(mockSignedUrlResponse);
expect(result).toEqual({ data: mockSignedUrlResponse });
});
test("should return internal server error response when getUploadSignedUrl throws an error", async () => {
const mockFileName = "test.png";
const mockEnvironmentId = "env456";
const mockFileType = "image/png";
const mockError = new Error("Failed to get signed URL");
vi.mocked(getUploadSignedUrl).mockRejectedValue(mockError);
const result = await getSignedUrlForPublicFile(mockFileName, mockEnvironmentId, mockFileType);
expect(getUploadSignedUrl).toHaveBeenCalledWith(mockFileName, mockEnvironmentId, mockFileType, "public");
expect(responses.internalServerErrorResponse).toHaveBeenCalledWith("Internal server error");
expect(result).toEqual({ message: "Internal server error" });
});
});
@@ -1,22 +0,0 @@
import { responses } from "@/app/lib/api/response";
import { getUploadSignedUrl } from "@/lib/storage/service";
export const getSignedUrlForPublicFile = async (
fileName: string,
environmentId: string,
fileType: string
) => {
const accessType = "public"; // public files are accessible by anyone
// if s3 is not configured, we'll upload to a local folder named uploads
try {
const signedUrlResponse = await getUploadSignedUrl(fileName, environmentId, fileType, accessType);
return responses.successResponse({
...signedUrlResponse,
});
} catch (err) {
return responses.internalServerErrorResponse("Internal server error");
}
};
@@ -4,7 +4,7 @@ import { hasPermission } from "@/modules/organization/settings/api-keys/lib/util
import { Session } from "next-auth";
import { describe, expect, test, vi } from "vitest";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { checkAuth, checkForRequiredFields } from "./utils";
import { checkAuth } from "./utils";
// Create mock response objects
const mockBadRequestResponse = new Response("Bad Request", { status: 400 });
@@ -27,49 +27,6 @@ vi.mock("@/app/lib/api/response", () => ({
},
}));
describe("checkForRequiredFields", () => {
test("should return undefined when all required fields are present", () => {
const result = checkForRequiredFields("env-123", "image/png", "test-file.png");
expect(result).toBeUndefined();
});
test("should return bad request response when environmentId is missing", () => {
const result = checkForRequiredFields("", "image/png", "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when fileType is missing", () => {
const result = checkForRequiredFields("env-123", "", "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when encodedFileName is missing", () => {
const result = checkForRequiredFields("env-123", "image/png", "");
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when environmentId is undefined", () => {
const result = checkForRequiredFields(undefined as any, "image/png", "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when fileType is undefined", () => {
const result = checkForRequiredFields("env-123", undefined as any, "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when encodedFileName is undefined", () => {
const result = checkForRequiredFields("env-123", "image/png", undefined as any);
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
expect(result).toBe(mockBadRequestResponse);
});
});
describe("checkAuth", () => {
const environmentId = "env-123";
@@ -3,24 +3,6 @@ import { TApiV1Authentication } from "@/app/lib/api/with-api-logging";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
export const checkForRequiredFields = (
environmentId: string,
fileType: string,
encodedFileName: string
): Response | undefined => {
if (!environmentId) {
return responses.badRequestResponse("environmentId is required");
}
if (!fileType) {
return responses.badRequestResponse("contentType is required");
}
if (!encodedFileName) {
return responses.badRequestResponse("fileName is required");
}
};
export const checkAuth = async (authentication: TApiV1Authentication, environmentId: string) => {
if (!authentication) {
return responses.notAuthenticatedResponse();
@@ -1,109 +0,0 @@
// headers -> "Content-Type" should be present and set to a valid MIME type
// body -> should be a valid file object (buffer)
// method -> PUT (to be the same as the signedUrl method)
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
import { responses } from "@/app/lib/api/response";
import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
import { validateLocalSignedUrl } from "@/lib/crypto";
import { validateFile } from "@/lib/fileValidation";
import { putFileToLocalStorage } from "@/lib/storage/service";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
export const POST = withV1ApiWrapper({
handler: async ({ req, authentication }: { req: NextRequest; authentication: TApiV1Authentication }) => {
if (!ENCRYPTION_KEY) {
return {
response: responses.internalServerErrorResponse("Encryption key is not set"),
};
}
const accessType = "public"; // public files are accessible by anyone
const jsonInput = await req.json();
const fileType = jsonInput.fileType as string;
const encodedFileName = jsonInput.fileName as string;
const signedSignature = jsonInput.signature as string;
const signedUuid = jsonInput.uuid as string;
const signedTimestamp = jsonInput.timestamp as string;
const environmentId = jsonInput.environmentId as string;
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, encodedFileName);
if (requiredFieldResponse) {
return {
response: requiredFieldResponse,
};
}
if (!signedSignature || !signedUuid || !signedTimestamp) {
return {
response: responses.unauthorizedResponse(),
};
}
const authResponse = await checkAuth(authentication, environmentId);
if (authResponse) return { response: authResponse };
const fileName = decodeURIComponent(encodedFileName);
// Perform server-side file validation
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return {
response: responses.badRequestResponse(fileValidation.error ?? "Invalid file"),
};
}
// validate signature
const validated = validateLocalSignedUrl(
signedUuid,
fileName,
environmentId,
fileType,
Number(signedTimestamp),
signedSignature,
ENCRYPTION_KEY
);
if (!validated) {
return {
response: responses.unauthorizedResponse(),
};
}
const base64String = jsonInput.fileBase64String as string;
const buffer = Buffer.from(base64String.split(",")[1], "base64");
const file = new Blob([buffer], { type: fileType });
if (!file) {
return {
response: responses.badRequestResponse("fileBuffer is required"),
};
}
try {
const bytes = await file.arrayBuffer();
const fileBuffer = Buffer.from(bytes);
await putFileToLocalStorage(fileName, fileBuffer, accessType, environmentId, UPLOADS_DIR);
return {
response: responses.successResponse({
message: "File uploaded successfully",
}),
};
} catch (err) {
logger.error(err, "Error uploading file");
if (err.name === "FileTooLargeError") {
return {
response: responses.badRequestResponse(err.message),
};
}
return {
response: responses.internalServerErrorResponse("File upload failed"),
};
}
},
});
+30 -28
View File
@@ -1,20 +1,22 @@
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
import { checkAuth } from "@/app/api/v1/management/storage/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFile } from "@/lib/fileValidation";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getSignedUrlForUpload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
import { TUploadPublicFileRequest, ZUploadPublicFileRequest } from "@formbricks/types/storage";
// api endpoint for uploading public files
// api endpoint for getting a signed url for uploading a public file
// uploaded files will be public, anyone can access the file
// uploading public files requires authentication
// use this to upload files for a specific resource, e.g. a user profile picture or a survey
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
// use this to get a signed url for uploading a public file for a specific resource, e.g. a survey's background image
export const POST = withV1ApiWrapper({
handler: async ({ req, authentication }: { req: NextRequest; authentication: TApiV1Authentication }) => {
let storageInput;
let storageInput: TUploadPublicFileRequest;
try {
storageInput = await req.json();
@@ -25,15 +27,24 @@ export const POST = withV1ApiWrapper({
};
}
const { fileName, fileType, environmentId, allowedFileExtensions } = storageInput;
const parsedInputResult = ZUploadPublicFileRequest.safeParse(storageInput);
if (!parsedInputResult.success) {
const errorDetails = transformErrorToDetails(parsedInputResult.error);
logger.error({ error: errorDetails }, "Fields are missing or incorrectly formatted");
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, fileName);
if (requiredFieldResponse) {
return {
response: requiredFieldResponse,
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
errorDetails,
true
),
};
}
const { fileName, fileType, environmentId } = parsedInputResult.data;
const authResponse = await checkAuth(authentication, environmentId);
if (authResponse) {
return {
@@ -41,28 +52,19 @@ export const POST = withV1ApiWrapper({
};
}
// Perform server-side file validation first to block dangerous file types
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
const signedUrlResponse = await getSignedUrlForUpload(fileName, environmentId, fileType, "public");
if (!signedUrlResponse.ok) {
logger.error({ error: signedUrlResponse.error }, "Error getting signed url for upload");
const errorResponse = getErrorResponseFromStorageError(signedUrlResponse.error, { fileName });
return {
response: responses.badRequestResponse(fileValidation.error ?? "Invalid file type"),
response: errorResponse,
};
}
// Also perform client-specified allowed file extensions validation if provided
if (allowedFileExtensions?.length) {
const fileExtension = fileName.split(".").pop()?.toLowerCase();
if (!fileExtension || !allowedFileExtensions.includes(fileExtension)) {
return {
response: responses.badRequestResponse(
`File extension is not allowed, allowed extensions are: ${allowedFileExtensions.join(", ")}`
),
};
}
}
return {
response: await getSignedUrlForPublicFile(fileName, environmentId, fileType),
response: responses.successResponse(signedUrlResponse.data),
};
},
customRateLimitConfig: rateLimitConfigs.storage.upload,
});
@@ -1,3 +0,0 @@
import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/local/route";
export { OPTIONS, POST };
+16 -8
View File
@@ -11,6 +11,7 @@ import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
@@ -37,6 +38,7 @@ export interface TWithV1ApiWrapperParams<TResult extends { response: Response },
handler: (params: THandlerParams<TProps>) => Promise<TResult>;
action?: TAuditAction;
targetType?: TAuditTarget;
customRateLimitConfig?: TRateLimitConfig;
}
enum ApiV1RouteTypeEnum {
@@ -48,13 +50,13 @@ enum ApiV1RouteTypeEnum {
/**
* Apply client-side API rate limiting (IP-based or sync-specific)
*/
const applyClientRateLimit = async (url: string): Promise<void> => {
const applyClientRateLimit = async (url: string, customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
const syncEndpoint = isSyncWithUserIdentificationEndpoint(url);
if (syncEndpoint) {
const syncRateLimitConfig = rateLimitConfigs.api.syncUserIdentification;
await applyRateLimit(syncRateLimitConfig, syncEndpoint.userId);
} else {
await applyIPRateLimit(rateLimitConfigs.api.client);
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
}
};
@@ -64,16 +66,17 @@ const applyClientRateLimit = async (url: string): Promise<void> => {
const handleRateLimiting = async (
url: string,
authentication: TApiV1Authentication,
routeType: ApiV1RouteTypeEnum
routeType: ApiV1RouteTypeEnum,
customRateLimitConfig?: TRateLimitConfig
): Promise<Response | null> => {
try {
if (authentication) {
if ("user" in authentication) {
// Session-based authentication for integration routes
await applyRateLimit(rateLimitConfigs.api.v1, authentication.user.id);
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.user.id);
} else if ("hashedApiKey" in authentication) {
// API key authentication for general routes
await applyRateLimit(rateLimitConfigs.api.v1, authentication.hashedApiKey);
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.hashedApiKey);
} else {
logger.error({ authentication }, "Unknown authentication type");
return responses.internalServerErrorResponse("Invalid authentication configuration");
@@ -81,7 +84,7 @@ const handleRateLimiting = async (
}
if (routeType === ApiV1RouteTypeEnum.Client) {
await applyClientRateLimit(url);
await applyClientRateLimit(url, customRateLimitConfig);
}
} catch (error) {
return responses.tooManyRequestsResponse(error.message);
@@ -282,7 +285,7 @@ export const withV1ApiWrapper: {
} = <TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { handler, action, targetType } = params;
const { handler, action, targetType, customRateLimitConfig } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
// === Audit Log Setup ===
const saveAuditLog = action && targetType;
@@ -312,7 +315,12 @@ export const withV1ApiWrapper: {
// === Rate Limiting ===
if (isRateLimited) {
const rateLimitResponse = await handleRateLimiting(req.nextUrl.pathname, authentication, routeType);
const rateLimitResponse = await handleRateLimiting(
req.nextUrl.pathname,
authentication,
routeType,
customRateLimitConfig
);
if (rateLimitResponse) return rateLimitResponse;
}
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("audit-logs lib", () => {
const envId = "env-123";
const apiUrl = "/storage/env-123/public/file.txt";
beforeEach(() => {
vi.clearAllMocks();
});
test("logs file deletion success with provided data", async () => {
const { getOrganizationIdFromEnvironmentId } = await import("@/lib/utils/helper");
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
const { logFileDeletion } = await import("./audit-logs");
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValueOnce("org-1");
await logFileDeletion({
environmentId: envId,
accessType: "public",
userId: "user-1",
status: "success",
oldObject: { key: "value" },
apiUrl,
});
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "file",
userId: "user-1",
userType: "user",
targetId: `${envId}:public`,
organizationId: "org-1",
status: "success",
oldObject: { key: "value" },
apiUrl,
newObject: expect.objectContaining({ environmentId: envId, accessType: "public" }),
})
);
});
test("logs with UNKNOWN_DATA userId when missing and includes failureReason", async () => {
const { getOrganizationIdFromEnvironmentId } = await import("@/lib/utils/helper");
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
const { UNKNOWN_DATA } = await import("@/modules/ee/audit-logs/types/audit-log");
const { logFileDeletion } = await import("./audit-logs");
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValueOnce("org-2");
await logFileDeletion({
environmentId: envId,
accessType: "private",
status: "failure",
failureReason: "S3 error",
apiUrl,
});
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
userId: UNKNOWN_DATA,
status: "failure",
organizationId: "org-2",
newObject: expect.objectContaining({ failureReason: "S3 error" }),
})
);
});
test("falls back to UNKNOWN_DATA organizationId when lookup fails", async () => {
const { getOrganizationIdFromEnvironmentId } = await import("@/lib/utils/helper");
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
const { UNKNOWN_DATA } = await import("@/modules/ee/audit-logs/types/audit-log");
const { logFileDeletion } = await import("./audit-logs");
vi.mocked(getOrganizationIdFromEnvironmentId).mockRejectedValueOnce(new Error("fail"));
await logFileDeletion({
environmentId: envId,
accessType: "public",
apiUrl,
});
expect(queueAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ organizationId: UNKNOWN_DATA }));
});
test("swallows errors from queueAuditEvent and logs", async () => {
const { getOrganizationIdFromEnvironmentId } = await import("@/lib/utils/helper");
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
const { logger } = await import("@formbricks/logger");
const { logFileDeletion } = await import("./audit-logs");
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValueOnce("org-3");
vi.mocked(queueAuditEvent).mockRejectedValueOnce(new Error("audit fail"));
await logFileDeletion({ environmentId: envId, apiUrl });
expect(logger.error).toHaveBeenCalled();
});
});
@@ -0,0 +1,54 @@
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { logger } from "@formbricks/logger";
const getOrgId = async (environmentId: string): Promise<string> => {
try {
return await getOrganizationIdFromEnvironmentId(environmentId);
} catch (error) {
logger.error({ error }, "Failed to get organization ID for environment");
return UNKNOWN_DATA;
}
};
export const logFileDeletion = async ({
environmentId,
accessType,
userId,
status = "failure",
failureReason,
oldObject,
apiUrl,
}: {
environmentId: string;
accessType?: string;
userId?: string;
status?: TAuditStatus;
failureReason?: string;
oldObject?: Record<string, unknown>;
apiUrl: string;
}) => {
try {
const organizationId = await getOrgId(environmentId);
await queueAuditEvent({
action: "deleted",
targetType: "file",
userId: userId || UNKNOWN_DATA, // NOSONAR // We want to check for empty user IDs too
userType: "user",
targetId: `${environmentId}:${accessType}`, // Generic target identifier
organizationId,
status,
newObject: {
environmentId,
accessType,
...(failureReason && { failureReason }),
},
oldObject,
apiUrl,
});
} catch (auditError) {
logger.error({ error: auditError }, "Failed to log file deletion audit event");
}
};
@@ -0,0 +1,54 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const authorizePrivateDownload = async (
request: NextRequest,
environmentId: string,
action: "GET" | "DELETE"
): Promise<
Result<
{ authType: "session"; userId: string } | { authType: "apiKey"; hashedApiKey: string },
{
unauthorized: boolean;
}
>
> => {
const session = await getServerSession(authOptions);
if (session?.user) {
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isUserAuthorized) {
return err({
unauthorized: true,
});
}
return ok({
authType: "session",
userId: session.user.id,
});
}
const auth = await authenticateRequest(request);
if (!auth) {
return err({
unauthorized: false,
});
}
if (!hasPermission(auth.environmentPermissions, environmentId, action)) {
return err({
unauthorized: true,
});
}
return ok({
authType: "apiKey",
hashedApiKey: auth.hashedApiKey,
});
};
@@ -1,21 +0,0 @@
import { responses } from "@/app/lib/api/response";
import { deleteFile } from "@/lib/storage/service";
import { type TAccessType } from "@formbricks/types/storage";
export const handleDeleteFile = async (environmentId: string, accessType: TAccessType, fileName: string) => {
try {
const { message, success, code } = await deleteFile(environmentId, accessType, fileName);
if (success) {
return responses.successResponse(message);
}
if (code === 404) {
return responses.notFoundResponse("File", "File not found");
}
return responses.internalServerErrorResponse(message);
} catch (err) {
return responses.internalServerErrorResponse("Something went wrong");
}
};
@@ -1,47 +0,0 @@
import { responses } from "@/app/lib/api/response";
import { UPLOADS_DIR, isS3Configured } from "@/lib/constants";
import { getLocalFile, getS3File } from "@/lib/storage/service";
import { notFound } from "next/navigation";
import path from "node:path";
export const getFile = async (
environmentId: string,
accessType: string,
fileName: string
): Promise<Response> => {
if (!isS3Configured()) {
try {
const { fileBuffer, metaData } = await getLocalFile(
path.join(UPLOADS_DIR, environmentId, accessType, fileName)
);
return new Response(fileBuffer, {
headers: {
"Content-Type": metaData.contentType,
"Content-Disposition": "attachment",
"Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
Vary: "Accept-Encoding",
},
});
} catch (err) {
return notFound();
}
}
try {
const signedUrl = await getS3File(`${environmentId}/${accessType}/${fileName}`);
return new Response(null, {
status: 302,
headers: {
Location: signedUrl,
"Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
},
});
} catch (error: unknown) {
if (error instanceof Error && error.name === "NoSuchKey") {
return responses.notFoundResponse("File not found", fileName);
}
return responses.internalServerErrorResponse("Internal server error");
}
};
@@ -1,24 +1,23 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { handleDeleteFile } from "@/app/storage/[environmentId]/[accessType]/[fileName]/lib/delete-file";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { authorizePrivateDownload } from "@/app/storage/[environmentId]/[accessType]/[fileName]/lib/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { deleteFile, getSignedUrlForDownload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
import { getServerSession } from "next-auth";
import { type NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZStorageRetrievalParams } from "@formbricks/types/storage";
import { getFile } from "./lib/get-file";
import { TAccessType, ZDeleteFileRequest, ZDownloadFileRequest } from "@formbricks/types/storage";
import { logFileDeletion } from "./lib/audit-logs";
export const GET = async (
request: NextRequest,
props: { params: Promise<{ environmentId: string; accessType: string; fileName: string }> }
props: { params: Promise<{ environmentId: string; accessType: TAccessType; fileName: string }> }
): Promise<Response> => {
const params = await props.params;
const paramValidation = ZStorageRetrievalParams.safeParse(params);
const paramValidation = ZDownloadFileRequest.safeParse(params);
if (!paramValidation.success) {
return responses.badRequestResponse(
@@ -28,36 +27,35 @@ export const GET = async (
);
}
const { environmentId, accessType, fileName: fileNameOG } = params;
const { environmentId, accessType, fileName } = paramValidation.data;
const fileName = decodeURIComponent(fileNameOG);
if (accessType === "public") {
return await getFile(environmentId, accessType, fileName);
}
// if the user is authenticated via the session
const session = await getServerSession(authOptions);
if (!session?.user) {
// check for api key auth
const res = await authenticateRequest(request);
if (!res) {
return responses.notAuthenticatedResponse();
// check auth
if (accessType === "private") {
const authResult = await authorizePrivateDownload(request, environmentId, "GET");
if (!authResult.ok) {
return authResult.error.unauthorized
? responses.unauthorizedResponse()
: responses.notAuthenticatedResponse();
}
return await getFile(environmentId, accessType, fileName);
}
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
const signedUrlResult = await getSignedUrlForDownload(fileName, environmentId, accessType);
if (!isUserAuthorized) {
return responses.unauthorizedResponse();
if (!signedUrlResult.ok) {
const errorResponse = getErrorResponseFromStorageError(signedUrlResult.error, { fileName });
return errorResponse;
}
return await getFile(environmentId, accessType, fileName);
return new Response(null, {
status: 302,
headers: {
Location: signedUrlResult.data,
"Cache-Control":
accessType === "private"
? "no-store, no-cache, must-revalidate"
: "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
},
});
};
export const DELETE = async (
@@ -65,157 +63,78 @@ export const DELETE = async (
props: { params: Promise<{ environmentId: string; accessType: string; fileName: string }> }
): Promise<Response> => {
const params = await props.params;
const getOrgId = async (environmentId: string): Promise<string> => {
try {
return await getOrganizationIdFromEnvironmentId(environmentId);
} catch (error) {
logger.error("Failed to get organization ID for environment", { error });
return UNKNOWN_DATA;
}
};
const logFileDeletion = async ({
accessType,
userId,
status = "failure",
failureReason,
oldObject,
}: {
accessType?: string;
userId?: string;
status?: TAuditStatus;
failureReason?: string;
oldObject?: Record<string, unknown>;
}) => {
try {
const organizationId = await getOrgId(environmentId);
await queueAuditEvent({
action: "deleted",
targetType: "file",
userId: userId || UNKNOWN_DATA, // NOSONAR // We want to check for empty user IDs too
userType: "user",
targetId: `${environmentId}:${accessType}`, // Generic target identifier
organizationId,
status,
newObject: {
environmentId,
accessType,
...(failureReason && { failureReason }),
},
oldObject,
apiUrl: request.url,
});
} catch (auditError) {
logger.error("Failed to log file deletion audit event:", auditError);
}
};
// Validation
if (!params.fileName) {
await logFileDeletion({
failureReason: "fileName parameter missing",
});
return responses.badRequestResponse("Fields are missing or incorrectly formatted", {
fileName: "fileName is required",
});
}
const { environmentId, accessType, fileName } = params;
// Security check: If fileName contains the same properties from the route, ensure they match
// This is to prevent a user from deleting a file from a different environment
const [fileEnvironmentId, fileAccessType, file] = fileName.split("/");
if (fileEnvironmentId !== environmentId) {
await logFileDeletion({
failureReason: "Environment ID mismatch between route and fileName",
accessType,
});
return responses.badRequestResponse("Environment ID mismatch", {
message: "The environment ID in the fileName does not match the route environment ID",
});
}
if (fileAccessType !== accessType) {
await logFileDeletion({
failureReason: "Access type mismatch between route and fileName",
accessType,
});
return responses.badRequestResponse("Access type mismatch", {
message: "The access type in the fileName does not match the route access type",
});
}
const paramValidation = ZStorageRetrievalParams.safeParse({ fileName: file, environmentId, accessType });
const paramValidation = ZDeleteFileRequest.safeParse(params);
if (!paramValidation.success) {
const errorDetails = transformErrorToDetails(paramValidation.error);
await logFileDeletion({
failureReason: "Parameter validation failed",
accessType,
environmentId: params.environmentId,
apiUrl: request.url,
});
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(paramValidation.error),
true
);
return responses.badRequestResponse("Fields are missing or incorrectly formatted", errorDetails, true);
}
const {
environmentId: validEnvId,
accessType: validAccessType,
fileName: validFileName,
} = paramValidation.data;
const { environmentId, accessType, fileName } = paramValidation.data;
// Authentication
const session = await getServerSession(authOptions);
if (!session?.user) {
const authResult = await authorizePrivateDownload(request, environmentId, "DELETE");
if (!authResult.ok) {
await logFileDeletion({
failureReason: "User not authenticated",
accessType: validAccessType,
failureReason: authResult.error.unauthorized
? "User not authorized to access environment"
: "User not authenticated",
accessType,
environmentId,
apiUrl: request.url,
});
return responses.notAuthenticatedResponse();
return authResult.error.unauthorized
? responses.unauthorizedResponse()
: responses.notAuthenticatedResponse();
}
// Authorization
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, validEnvId);
if (!isUserAuthorized) {
await logFileDeletion({
failureReason: "User not authorized to access environment",
accessType: validAccessType,
userId: session.user.id,
});
return responses.unauthorizedResponse();
}
try {
const deleteResult = await handleDeleteFile(validEnvId, validAccessType, validFileName);
const isSuccess = deleteResult.status === 200;
let failureReason = "File deletion failed";
if (!isSuccess) {
try {
const responseBody = await deleteResult.json();
failureReason = responseBody.message || failureReason; // NOSONAR // We want to check for empty messages too
} catch (error) {
logger.error("Failed to parse file delete error response body", { error });
if (authResult.ok) {
try {
if (authResult.data.authType === "apiKey") {
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.hashedApiKey);
} else {
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.userId);
}
} catch (error) {
return responses.tooManyRequestsResponse(error.message);
}
await logFileDeletion({
status: isSuccess ? "success" : "failure",
failureReason: isSuccess ? undefined : failureReason,
accessType: validAccessType,
userId: session.user.id,
});
return deleteResult;
} catch (error) {
await logFileDeletion({
failureReason: error instanceof Error ? error.message : "Unexpected error during file deletion",
accessType: validAccessType,
userId: session.user.id,
});
throw error;
}
const deleteResult = await deleteFile(environmentId, accessType, decodeURIComponent(fileName));
const isSuccess = deleteResult.ok;
if (!isSuccess) {
logger.error({ error: deleteResult.error }, "Error deleting file");
await logFileDeletion({
failureReason: deleteResult.error.code,
accessType,
userId: session?.user?.id,
environmentId,
apiUrl: request.url,
});
const errorResponse = getErrorResponseFromStorageError(deleteResult.error, { fileName });
return errorResponse;
}
await logFileDeletion({
status: "success",
accessType,
userId: session?.user?.id,
environmentId,
apiUrl: request.url,
});
return responses.successResponse("File deleted successfully");
};
+2 -10
View File
@@ -110,19 +110,11 @@ export const S3_REGION = env.S3_REGION;
export const S3_ENDPOINT_URL = env.S3_ENDPOINT_URL;
export const S3_BUCKET_NAME = env.S3_BUCKET_NAME;
export const S3_FORCE_PATH_STYLE = env.S3_FORCE_PATH_STYLE === "1";
export const UPLOADS_DIR = env.UPLOADS_DIR ?? "./uploads";
export const MAX_SIZES = {
export const MAX_FILE_UPLOAD_SIZES = {
standard: 1024 * 1024 * 10, // 10MB
big: 1024 * 1024 * 1024, // 1GB
} as const;
// Function to check if the necessary S3 configuration is set up
export const isS3Configured = () => {
// This function checks if the S3 bucket name environment variable is defined.
// The AWS SDK automatically resolves credentials through a chain,
// so we do not need to explicitly check for AWS credentials like access key, secret key, or region.
return !!S3_BUCKET_NAME;
};
export const IS_STORAGE_CONFIGURED = Boolean(S3_ACCESS_KEY && S3_SECRET_KEY && S3_REGION && S3_BUCKET_NAME);
// Colors for Survey Bg
export const SURVEY_BG_COLORS = [
+1 -19
View File
@@ -1,12 +1,6 @@
import { createCipheriv, randomBytes } from "crypto";
import { describe, expect, test, vi } from "vitest";
import {
generateLocalSignedUrl,
getHash,
symmetricDecrypt,
symmetricEncrypt,
validateLocalSignedUrl,
} from "./crypto";
import { getHash, symmetricDecrypt, symmetricEncrypt } from "./crypto";
vi.mock("./constants", () => ({ ENCRYPTION_KEY: "0".repeat(32) }));
@@ -44,16 +38,4 @@ describe("crypto", () => {
expect(typeof h).toBe("string");
expect(h.length).toBeGreaterThan(0);
});
test("signed URL generation & validation", () => {
const { uuid, timestamp, signature } = generateLocalSignedUrl("f", "e", "t");
expect(uuid).toHaveLength(32);
expect(typeof timestamp).toBe("number");
expect(typeof signature).toBe("string");
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp, signature, key)).toBe(true);
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp, "bad", key)).toBe(false);
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp - 1000 * 60 * 6, signature, key)).toBe(
false
);
});
});
+1 -37
View File
@@ -1,4 +1,4 @@
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "crypto";
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
import { logger } from "@formbricks/logger";
import { ENCRYPTION_KEY } from "./constants";
@@ -92,39 +92,3 @@ export function symmetricDecrypt(payload: string, key: string): string {
}
export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex");
export const generateLocalSignedUrl = (
fileName: string,
environmentId: string,
fileType: string
): { signature: string; uuid: string; timestamp: number } => {
const uuid = randomBytes(16).toString("hex");
const timestamp = Date.now();
const data = `${uuid}:${fileName}:${environmentId}:${fileType}:${timestamp}`;
const signature = createHmac("sha256", ENCRYPTION_KEY).update(data).digest("hex");
return { signature, uuid, timestamp };
};
export const validateLocalSignedUrl = (
uuid: string,
fileName: string,
environmentId: string,
fileType: string,
timestamp: number,
signature: string,
secret: string
): boolean => {
const data = `${uuid}:${fileName}:${environmentId}:${fileType}:${timestamp}`;
const expectedSignature = createHmac("sha256", secret).update(data).digest("hex");
if (expectedSignature !== signature) {
return false;
}
// valid for 5 minutes
if (Date.now() - timestamp > 1000 * 60 * 5) {
return false;
}
return true;
};
-2
View File
@@ -111,7 +111,6 @@ export const env = createEnv({
TURNSTILE_SITE_KEY: z.string().optional(),
RECAPTCHA_SITE_KEY: z.string().optional(),
RECAPTCHA_SECRET_KEY: z.string().optional(),
UPLOADS_DIR: z.string().min(1).optional(),
VERCEL_URL: z.string().optional(),
WEBAPP_URL: z.string().url().optional(),
UNSPLASH_ACCESS_KEY: z.string().optional(),
@@ -212,7 +211,6 @@ export const env = createEnv({
RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,
RECAPTCHA_SECRET_KEY: process.env.RECAPTCHA_SECRET_KEY,
TERMS_URL: process.env.TERMS_URL,
UPLOADS_DIR: process.env.UPLOADS_DIR,
VERCEL_URL: process.env.VERCEL_URL,
WEBAPP_URL: process.env.WEBAPP_URL,
UNSPLASH_ACCESS_KEY: process.env.UNSPLASH_ACCESS_KEY,
-95
View File
@@ -1,95 +0,0 @@
import { getOriginalFileNameFromUrl } from "@/lib/storage/utils";
import { TAllowedFileExtension, ZAllowedFileExtension, mimeTypes } from "@formbricks/types/common";
import { TResponseData } from "@formbricks/types/responses";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
/**
* Validates if the file extension is allowed
* @param fileName The name of the file to validate
* @returns {boolean} True if the file extension is allowed, false otherwise
*/
export const isAllowedFileExtension = (fileName: string): boolean => {
// Extract the file extension
const extension = fileName.split(".").pop()?.toLowerCase();
if (!extension || extension === fileName.toLowerCase()) return false;
// Check if the extension is in the allowed list
return Object.values(ZAllowedFileExtension.enum).includes(extension as TAllowedFileExtension);
};
/**
* Validates if the file type matches the extension
* @param fileName The name of the file
* @param mimeType The MIME type of the file
* @returns {boolean} True if the file type matches the extension, false otherwise
*/
export const isValidFileTypeForExtension = (fileName: string, mimeType: string): boolean => {
const extension = fileName.split(".").pop()?.toLowerCase();
if (!extension || extension === fileName.toLowerCase()) return false;
// Basic MIME type validation for common file types
const mimeTypeLower = mimeType.toLowerCase();
// Check if the MIME type matches the expected type for this extension
return mimeTypes[extension] === mimeTypeLower;
};
/**
* Validates a file for security concerns
* @param fileName The name of the file to validate
* @param mimeType The MIME type of the file
* @returns {object} An object with validation result and error message if any
*/
export const validateFile = (fileName: string, mimeType: string): { valid: boolean; error?: string } => {
// Check for disallowed extensions
if (!isAllowedFileExtension(fileName)) {
return { valid: false, error: "File type not allowed for security reasons." };
}
// Check if the file type matches the extension
if (!isValidFileTypeForExtension(fileName, mimeType)) {
return { valid: false, error: "File type doesn't match the file extension." };
}
return { valid: true };
};
export const validateSingleFile = (
fileUrl: string,
allowedFileExtensions?: TAllowedFileExtension[]
): boolean => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
if (!fileName) return false;
const extension = fileName.split(".").pop();
if (!extension) return false;
return !allowedFileExtensions || allowedFileExtensions.includes(extension as TAllowedFileExtension);
};
export const validateFileUploads = (data?: TResponseData, questions?: TSurveyQuestion[]): boolean => {
if (!data) return true;
for (const key of Object.keys(data)) {
const question = questions?.find((q) => q.id === key);
if (!question || question.type !== TSurveyQuestionTypeEnum.FileUpload) continue;
const fileUrls = data[key];
if (!Array.isArray(fileUrls) || !fileUrls.every((url) => typeof url === "string")) return false;
for (const fileUrl of fileUrls) {
if (!validateSingleFile(fileUrl, question.allowedFileExtensions)) return false;
}
}
return true;
};
export const isValidImageFile = (fileUrl: string): boolean => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
if (!fileName || fileName.endsWith(".")) return false;
const extension = fileName.split(".").pop()?.toLowerCase();
if (!extension) return false;
const imageExtensions = ["png", "jpeg", "jpg", "webp", "heic"];
return imageExtensions.includes(extension);
};
+12 -14
View File
@@ -1,6 +1,7 @@
import "server-only";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
import { deleteFile } from "@/modules/storage/service";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { Prisma } from "@prisma/client";
@@ -21,9 +22,8 @@ import {
} from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { ITEMS_PER_PAGE, WEBAPP_URL } from "../constants";
import { ITEMS_PER_PAGE } from "../constants";
import { deleteDisplay } from "../display/service";
import { deleteFile, putFile } from "../storage/service";
import { getSurvey } from "../survey/service";
import { convertToCsv, convertToXlsxBuffer } from "../utils/file-conversion";
import { validateInputs } from "../utils/validate";
@@ -336,11 +336,11 @@ export const getResponses = reactCache(
}
);
export const getResponseDownloadUrl = async (
export const getResponseDownloadFile = async (
surveyId: string,
format: "csv" | "xlsx",
filterCriteria?: TResponseFilterCriteria
): Promise<string> => {
): Promise<{ fileContents: string; fileName: string }> => {
validateInputs([surveyId, ZId], [format, ZString], [filterCriteria, ZResponseFilterCriteria.optional()]);
try {
const survey = await getSurvey(surveyId);
@@ -349,9 +349,6 @@ export const getResponseDownloadUrl = async (
throw new ResourceNotFoundError("Survey", surveyId);
}
const environmentId = survey.environmentId;
const accessType = "private";
const batchSize = 3000;
// Use cursor-based pagination instead of count + offset to avoid expensive queries
@@ -419,18 +416,19 @@ export const getResponseDownloadUrl = async (
);
const fileName = getResponsesFileName(survey?.name || "", format);
let fileBuffer: Buffer;
let fileContents: string;
if (format === "xlsx") {
fileBuffer = convertToXlsxBuffer(headers, jsonData);
const buffer = convertToXlsxBuffer(headers, jsonData);
fileContents = buffer.toString("base64");
} else {
const csvFile = await convertToCsv(headers, jsonData);
fileBuffer = Buffer.from(csvFile);
fileContents = await convertToCsv(headers, jsonData);
}
await putFile(fileName, fileBuffer, accessType, environmentId);
return `${WEBAPP_URL}/storage/${environmentId}/${accessType}/${fileName}`;
return {
fileContents,
fileName,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
+14 -14
View File
@@ -30,7 +30,7 @@ import {
getResponse,
getResponseBySingleUseId,
getResponseCountBySurveyId,
getResponseDownloadUrl,
getResponseDownloadFile,
getResponsesByEnvironmentId,
responseSelection,
updateResponse,
@@ -80,12 +80,12 @@ beforeEach(() => {
prisma.response.findMany.mockResolvedValue([mockResponse]);
prisma.response.delete.mockResolvedValue(mockResponse);
prisma.display.delete.mockResolvedValue({ ...mockDisplay, status: "seen" });
prisma.display.delete.mockResolvedValue({ ...mockDisplay, status: "seen" } as unknown as any);
prisma.response.count.mockResolvedValue(1);
prisma.organization.findFirst.mockResolvedValue(mockOrganizationOutput);
prisma.organization.findUnique.mockResolvedValue(mockOrganizationOutput);
prisma.organization.findFirst.mockResolvedValue(mockOrganizationOutput as unknown as any);
prisma.organization.findUnique.mockResolvedValue(mockOrganizationOutput as unknown as any);
prisma.project.findMany.mockResolvedValue([]);
// @ts-expect-error
prisma.response.aggregate.mockResolvedValue({ _count: { id: 1 } });
@@ -211,8 +211,8 @@ describe("Tests for getResponseDownloadUrl service", () => {
prisma.response.findMany.mockResolvedValue([mockResponseWithQuotas]);
prisma.surveyQuota.findMany.mockResolvedValue([]);
const url = await getResponseDownloadUrl(mockSurveyId, "csv");
const fileExtension = url.split(".").pop();
const result = await getResponseDownloadFile(mockSurveyId, "csv");
const fileExtension = result.fileName.split(".").pop();
expect(fileExtension).toEqual("csv");
});
@@ -221,22 +221,22 @@ describe("Tests for getResponseDownloadUrl service", () => {
prisma.response.count.mockResolvedValue(1);
prisma.response.findMany.mockResolvedValue([mockResponseWithQuotas]);
const url = await getResponseDownloadUrl(mockSurveyId, "xlsx", { finished: true });
const fileExtension = url.split(".").pop();
const result = await getResponseDownloadFile(mockSurveyId, "xlsx", { finished: true });
const fileExtension = result.fileName.split(".").pop();
expect(fileExtension).toEqual("xlsx");
});
});
describe("Sad Path", () => {
testInputValidation(getResponseDownloadUrl, mockSurveyId, 123);
testInputValidation(getResponseDownloadFile, mockSurveyId, 123);
test("Throws error if response file is of different format than expected", async () => {
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
prisma.response.count.mockResolvedValue(1);
prisma.response.findMany.mockResolvedValue([mockResponseWithQuotas]);
const url = await getResponseDownloadUrl(mockSurveyId, "csv", { finished: true });
const fileExtension = url.split(".").pop();
const result = await getResponseDownloadFile(mockSurveyId, "csv", { finished: true });
const fileExtension = result.fileName.split(".").pop();
expect(fileExtension).not.toEqual("xlsx");
});
@@ -249,7 +249,7 @@ describe("Tests for getResponseDownloadUrl service", () => {
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
prisma.response.findMany.mockRejectedValue(errToThrow);
await expect(getResponseDownloadUrl(mockSurveyId, "csv")).rejects.toThrow(DatabaseError);
await expect(getResponseDownloadFile(mockSurveyId, "csv")).rejects.toThrow(DatabaseError);
});
test("Throws DatabaseError on PrismaClientKnownRequestError, when the getResponses fails", async () => {
@@ -263,7 +263,7 @@ describe("Tests for getResponseDownloadUrl service", () => {
prisma.response.count.mockResolvedValue(1);
prisma.response.findMany.mockRejectedValue(errToThrow);
await expect(getResponseDownloadUrl(mockSurveyId, "csv")).rejects.toThrow(DatabaseError);
await expect(getResponseDownloadFile(mockSurveyId, "csv")).rejects.toThrow(DatabaseError);
});
test("Throws a generic Error for unexpected problems", async () => {
@@ -272,7 +272,7 @@ describe("Tests for getResponseDownloadUrl service", () => {
// error from getSurvey
prisma.survey.findUnique.mockRejectedValue(new Error(mockErrorMessage));
await expect(getResponseDownloadUrl(mockSurveyId, "xlsx")).rejects.toThrow(Error);
await expect(getResponseDownloadFile(mockSurveyId, "xlsx")).rejects.toThrow(Error);
});
});
});
-312
View File
@@ -1,312 +0,0 @@
import { S3Client } from "@aws-sdk/client-s3";
import { readFile } from "fs/promises";
import { lookup } from "mime-types";
import path from "path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// Mock AWS SDK
const mockSend = vi.fn();
const mockS3Client = {
send: mockSend,
};
vi.mock("fs/promises", () => ({
readFile: vi.fn(),
access: vi.fn(),
mkdir: vi.fn(),
rmdir: vi.fn(),
unlink: vi.fn(),
writeFile: vi.fn(),
}));
vi.mock("mime-types", () => ({
lookup: vi.fn(),
}));
vi.mock("@aws-sdk/client-s3", () => ({
S3Client: vi.fn(() => mockS3Client),
HeadBucketCommand: vi.fn(),
PutObjectCommand: vi.fn(),
DeleteObjectCommand: vi.fn(),
GetObjectCommand: vi.fn(),
}));
vi.mock("@aws-sdk/s3-presigned-post", () => ({
createPresignedPost: vi.fn(() =>
Promise.resolve({
url: "https://test-bucket.s3.test-region.amazonaws.com",
fields: { key: "test-key", policy: "test-policy" },
})
),
}));
// Mock environment variables
vi.mock("../constants", () => ({
S3_ACCESS_KEY: "test-access-key",
S3_SECRET_KEY: "test-secret-key",
S3_REGION: "test-region",
S3_BUCKET_NAME: "test-bucket",
S3_ENDPOINT_URL: "http://test-endpoint",
S3_FORCE_PATH_STYLE: true,
isS3Configured: () => true,
IS_FORMBRICKS_CLOUD: false,
MAX_SIZES: {
standard: 5 * 1024 * 1024,
big: 10 * 1024 * 1024,
},
WEBAPP_URL: "http://test-webapp",
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!!",
UPLOADS_DIR: "/tmp/uploads",
}));
// Mock getPublicDomain
vi.mock("../getPublicUrl", () => ({
getPublicDomain: () => "https://public-domain.com",
}));
// Mock crypto functions
vi.mock("crypto", () => ({
randomUUID: () => "test-uuid",
}));
// Mock local signed url generation
vi.mock("../crypto", () => ({
generateLocalSignedUrl: () => ({
signature: "test-signature",
timestamp: 123456789,
uuid: "test-uuid",
}),
}));
// Mock env
vi.mock("../env", () => ({
env: {
S3_BUCKET_NAME: "test-bucket",
},
}));
describe("Storage Service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("getS3Client", () => {
test("should create and return S3 client instance", async () => {
const { getS3Client } = await import("./service");
const client = getS3Client();
expect(client).toBe(mockS3Client);
expect(S3Client).toHaveBeenCalledWith({
credentials: {
accessKeyId: "test-access-key",
secretAccessKey: "test-secret-key",
},
region: "test-region",
endpoint: "http://test-endpoint",
forcePathStyle: true,
});
});
test("should return existing client instance on subsequent calls", async () => {
vi.resetModules();
const { getS3Client } = await import("./service");
const client1 = getS3Client();
const client2 = getS3Client();
expect(client1).toBe(client2);
expect(S3Client).toHaveBeenCalledTimes(1);
});
});
describe("testS3BucketAccess", () => {
let testS3BucketAccess: any;
beforeEach(async () => {
const serviceModule = await import("./service");
testS3BucketAccess = serviceModule.testS3BucketAccess;
});
test("should return true when bucket access is successful", async () => {
mockSend.mockResolvedValueOnce({});
const result = await testS3BucketAccess();
expect(result).toBe(true);
expect(mockSend).toHaveBeenCalledTimes(1);
});
test("should throw error when bucket access fails", async () => {
const error = new Error("Access denied");
mockSend.mockRejectedValueOnce(error);
await expect(testS3BucketAccess()).rejects.toThrow(
"S3 Bucket Access Test Failed: Error: Access denied"
);
});
});
describe("putFile", () => {
let putFile: any;
beforeEach(async () => {
const serviceModule = await import("./service");
putFile = serviceModule.putFile;
});
test("should successfully upload file to S3", async () => {
const fileName = "test.jpg";
const fileBuffer = Buffer.from("test");
const accessType = "private";
const environmentId = "env123";
mockSend.mockResolvedValueOnce({});
const result = await putFile(fileName, fileBuffer, accessType, environmentId);
expect(result).toEqual({ success: true, message: "File uploaded" });
expect(mockSend).toHaveBeenCalledTimes(1);
});
test("should throw error when S3 upload fails", async () => {
const fileName = "test.jpg";
const fileBuffer = Buffer.from("test");
const accessType = "private";
const environmentId = "env123";
const error = new Error("Upload failed");
mockSend.mockRejectedValueOnce(error);
await expect(putFile(fileName, fileBuffer, accessType, environmentId)).rejects.toThrow("Upload failed");
});
});
describe("getUploadSignedUrl", () => {
let getUploadSignedUrl: any;
beforeEach(async () => {
const serviceModule = await import("./service");
getUploadSignedUrl = serviceModule.getUploadSignedUrl;
});
test("should use PUBLIC_URL for public files with S3", async () => {
const result = await getUploadSignedUrl("test.jpg", "env123", "image/jpeg", "public");
expect(result.fileUrl).toContain("https://public-domain.com");
expect(result.fileUrl).toMatch(
/https:\/\/public-domain\.com\/storage\/env123\/public\/test--fid--test-uuid\.jpg/
);
});
test("should use WEBAPP_URL for private files with S3", async () => {
const result = await getUploadSignedUrl("test.jpg", "env123", "image/jpeg", "private");
expect(result.fileUrl).toContain("http://test-webapp");
expect(result.fileUrl).toMatch(
/http:\/\/test-webapp\/storage\/env123\/private\/test--fid--test-uuid\.jpg/
);
});
test("should contain signed URL and presigned fields for S3", async () => {
const result = await getUploadSignedUrl("test.jpg", "env123", "image/jpeg", "public");
expect(result.signedUrl).toBe("https://test-bucket.s3.test-region.amazonaws.com");
expect(result.presignedFields).toEqual({ key: "test-key", policy: "test-policy" });
});
test("use local storage for private files when S3 is not configured", async () => {
vi.resetModules();
vi.doMock("../constants", () => ({
S3_ACCESS_KEY: "test-access-key",
S3_SECRET_KEY: "test-secret-key",
S3_REGION: "test-region",
S3_BUCKET_NAME: "test-bucket",
S3_ENDPOINT_URL: "http://test-endpoint",
S3_FORCE_PATH_STYLE: true,
isS3Configured: () => false,
IS_FORMBRICKS_CLOUD: false,
MAX_SIZES: {
standard: 5 * 1024 * 1024,
big: 10 * 1024 * 1024,
},
WEBAPP_URL: "http://test-webapp",
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!!",
UPLOADS_DIR: "/tmp/uploads",
}));
vi.mock("../getPublicUrl", () => ({
getPublicDomain: () => "https://public-domain.com",
}));
const freshModule = await import("./service");
const freshGetUploadSignedUrl = freshModule.getUploadSignedUrl as typeof getUploadSignedUrl;
const result = await freshGetUploadSignedUrl("test.jpg", "env123", "image/jpeg", "private");
expect(result.fileUrl).toContain("http://test-webapp");
expect(result.fileUrl).toMatch(
/http:\/\/test-webapp\/storage\/env123\/private\/test--fid--test-uuid\.jpg/
);
expect(result.fileUrl).not.toContain("test-bucket");
expect(result.fileUrl).not.toContain("test-endpoint");
});
});
describe("getLocalFile", () => {
let getLocalFile: any;
beforeEach(async () => {
const serviceModule = await import("./service");
getLocalFile = serviceModule.getLocalFile;
});
test("should return file buffer and metadata", async () => {
vi.mocked(readFile).mockResolvedValue(Buffer.from("test"));
vi.mocked(lookup).mockReturnValue("image/jpeg");
const result = await getLocalFile("/tmp/uploads/test/test.jpg");
expect(result.fileBuffer).toBeInstanceOf(Buffer);
expect(result.metaData).toEqual({ contentType: "image/jpeg" });
});
test("should throw error when file does not exist", async () => {
vi.mocked(readFile).mockRejectedValue(new Error("File not found"));
await expect(getLocalFile("/tmp/uploads/test/test.jpg")).rejects.toThrow("File not found");
});
test("should throw error when file path attempts traversal outside uploads dir", async () => {
const traversalOutside = path.join("/tmp/uploads", "../outside.txt");
await expect(getLocalFile(traversalOutside)).rejects.toThrow(
"Invalid file path: Path must be within uploads folder"
);
});
test("should reject path traversal using '../secret' with security error", async () => {
await expect(getLocalFile("../secret")).rejects.toThrow(
"Invalid file path: Path must be within uploads folder"
);
});
test("should reject Windows-style traversal '..\\\\secret' with security error", async () => {
await expect(getLocalFile("..\\secret")).rejects.toThrow(
"Invalid file path: Path must be within uploads folder"
);
});
test("should reject nested traversal 'subdir/../../etc/passwd' with security error", async () => {
await expect(getLocalFile("subdir/../../etc/passwd")).rejects.toThrow(
"Invalid file path: Path must be within uploads folder"
);
});
test("should throw EISDIR when provided path is a directory inside uploads", async () => {
// Simulate Node throwing EISDIR when attempting to read a directory
const eisdirError: any = new Error("EISDIR: illegal operation on a directory, read");
eisdirError.code = "EISDIR";
vi.mocked(readFile).mockRejectedValueOnce(eisdirError);
await expect(getLocalFile("/tmp/uploads/some-dir")).rejects.toMatchObject({
code: "EISDIR",
message: expect.stringContaining("EISDIR"),
});
});
});
});
-435
View File
@@ -1,435 +0,0 @@
import {
DeleteObjectCommand,
DeleteObjectsCommand,
GetObjectCommand,
HeadBucketCommand,
ListObjectsCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { PresignedPostOptions, createPresignedPost } from "@aws-sdk/s3-presigned-post";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "crypto";
import { access, mkdir, readFile, rmdir, unlink, writeFile } from "fs/promises";
import { lookup } from "mime-types";
import type { WithImplicitCoercion } from "node:buffer";
import path, { join } from "path";
import { logger } from "@formbricks/logger";
import { TAccessType } from "@formbricks/types/storage";
import {
IS_FORMBRICKS_CLOUD,
MAX_SIZES,
S3_ACCESS_KEY,
S3_BUCKET_NAME,
S3_ENDPOINT_URL,
S3_FORCE_PATH_STYLE,
S3_REGION,
S3_SECRET_KEY,
UPLOADS_DIR,
WEBAPP_URL,
isS3Configured,
} from "../constants";
import { generateLocalSignedUrl } from "../crypto";
import { env } from "../env";
import { getPublicDomain } from "../getPublicUrl";
// S3Client Singleton
let s3ClientInstance: S3Client | null = null;
export const getS3Client = () => {
if (!s3ClientInstance) {
const credentials =
S3_ACCESS_KEY && S3_SECRET_KEY
? { accessKeyId: S3_ACCESS_KEY, secretAccessKey: S3_SECRET_KEY }
: undefined;
s3ClientInstance = new S3Client({
credentials,
region: S3_REGION,
...(S3_ENDPOINT_URL && { endpoint: S3_ENDPOINT_URL }),
forcePathStyle: S3_FORCE_PATH_STYLE,
});
}
return s3ClientInstance;
};
export const testS3BucketAccess = async () => {
const s3Client = getS3Client();
try {
// Attempt to retrieve metadata about the bucket
const headBucketCommand = new HeadBucketCommand({
Bucket: S3_BUCKET_NAME,
});
await s3Client.send(headBucketCommand);
return true;
} catch (error) {
logger.error(error, "Failed to access S3 bucket");
throw new Error(`S3 Bucket Access Test Failed: ${error}`);
}
};
// Helper function to validate file paths are within the uploads directory
const validateAndResolvePath = (filePath: string): string => {
// Resolve and normalize the path to prevent directory traversal attacks
const resolvedPath = path.resolve(filePath);
const uploadsPath = path.resolve(UPLOADS_DIR);
// Ensure the resolved path is within the uploads directory
if (!resolvedPath.startsWith(uploadsPath)) {
throw new Error("Invalid file path: Path must be within uploads folder");
}
return resolvedPath;
};
const ensureDirectoryExists = async (dirPath: string) => {
const safePath = validateAndResolvePath(dirPath);
try {
await access(safePath);
} catch (error: any) {
if (error.code === "ENOENT") {
await mkdir(safePath, { recursive: true });
} else {
throw error;
}
}
};
type TGetFileResponse = {
fileBuffer: Buffer;
metaData: {
contentType: string;
};
};
// discriminated union
type TGetSignedUrlResponse =
| { signedUrl: string; fileUrl: string; presignedFields: Object }
| {
signedUrl: string;
updatedFileName: string;
fileUrl: string;
signingData: {
signature: string;
timestamp: number;
uuid: string;
};
};
const getS3SignedUrl = async (fileKey: string): Promise<string> => {
const getObjectCommand = new GetObjectCommand({
Bucket: S3_BUCKET_NAME,
Key: fileKey,
});
try {
const s3Client = getS3Client();
return await getSignedUrl(s3Client, getObjectCommand, { expiresIn: 30 * 60 });
} catch (err) {
throw err;
}
};
export const getS3File = async (fileKey: string): Promise<string> => {
const signedUrl = await getS3SignedUrl(fileKey);
return signedUrl;
};
export const getLocalFile = async (filePath: string): Promise<TGetFileResponse> => {
try {
const safeFilePath = validateAndResolvePath(filePath);
const file = await readFile(safeFilePath);
let contentType = "";
try {
contentType = lookup(filePath) || "";
} catch (err) {
throw err;
}
return {
fileBuffer: file,
metaData: {
contentType: contentType ?? "",
},
};
} catch (err) {
throw err;
}
};
// a single service for generating a signed url based on user's environment variables
export const getUploadSignedUrl = async (
fileName: string,
environmentId: string,
fileType: string,
accessType: TAccessType,
isBiggerFileUploadAllowed: boolean = false
): Promise<TGetSignedUrlResponse> => {
// add a unique id to the file name
const fileExtension = fileName.split(".").pop();
const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join(".");
if (!fileExtension) {
throw new Error("File extension not found");
}
const updatedFileName = `${fileNameWithoutExtension}--fid--${randomUUID()}.${fileExtension}`;
// Use PUBLIC_URL for public files, WEBAPP_URL for private files
const publicDomain = getPublicDomain();
const baseUrl = accessType === "public" ? getPublicDomain() : WEBAPP_URL;
// handle the local storage case first
if (!isS3Configured()) {
try {
const { signature, timestamp, uuid } = generateLocalSignedUrl(updatedFileName, environmentId, fileType);
return {
signedUrl:
accessType === "private"
? new URL(`${publicDomain}/api/v1/client/${environmentId}/storage/local`).href
: new URL(`${WEBAPP_URL}/api/v1/management/storage/local`).href,
signingData: {
signature,
timestamp,
uuid,
},
updatedFileName,
fileUrl: new URL(`${baseUrl}/storage/${environmentId}/${accessType}/${updatedFileName}`).href,
};
} catch (err) {
throw err;
}
}
try {
const { presignedFields, signedUrl } = await getS3UploadSignedUrl(
updatedFileName,
fileType,
accessType,
environmentId,
isBiggerFileUploadAllowed
);
return {
signedUrl,
presignedFields,
fileUrl: new URL(`${baseUrl}/storage/${environmentId}/${accessType}/${updatedFileName}`).href,
};
} catch (err) {
throw err;
}
};
export const getS3UploadSignedUrl = async (
fileName: string,
contentType: string,
accessType: string,
environmentId: string,
isBiggerFileUploadAllowed: boolean = false
) => {
const maxSize = IS_FORMBRICKS_CLOUD
? isBiggerFileUploadAllowed
? MAX_SIZES.big
: MAX_SIZES.standard
: Infinity;
const postConditions: PresignedPostOptions["Conditions"] = IS_FORMBRICKS_CLOUD
? [["content-length-range", 0, maxSize]]
: undefined;
try {
const s3Client = getS3Client();
const { fields, url } = await createPresignedPost(s3Client, {
Expires: 10 * 60, // 10 minutes
Bucket: env.S3_BUCKET_NAME!,
Key: `${environmentId}/${accessType}/${fileName}`,
Fields: {
"Content-Type": contentType,
"Content-Encoding": "base64",
},
Conditions: postConditions,
});
return {
signedUrl: url,
presignedFields: fields,
};
} catch (err) {
throw err;
}
};
export const putFileToLocalStorage = async (
fileName: string,
fileBuffer: Buffer,
accessType: string,
environmentId: string,
rootDir: string,
isBiggerFileUploadAllowed: boolean = false
) => {
try {
await ensureDirectoryExists(`${rootDir}/${environmentId}/${accessType}`);
const uploadPath = `${rootDir}/${environmentId}/${accessType}/${fileName}`;
const safeUploadPath = validateAndResolvePath(uploadPath);
const buffer = Buffer.from(fileBuffer as unknown as WithImplicitCoercion<string>);
const bufferBytes = buffer.byteLength;
const maxSize = IS_FORMBRICKS_CLOUD
? isBiggerFileUploadAllowed
? MAX_SIZES.big
: MAX_SIZES.standard
: Infinity;
if (bufferBytes > maxSize) {
const err = new Error(`File size exceeds the ${maxSize / (1024 * 1024)} MB limit`);
err.name = "FileTooLargeError";
throw err;
}
await writeFile(safeUploadPath, buffer as unknown as any);
} catch (err) {
throw err;
}
};
// a single service to put file in the storage(local or S3), based on the S3 configuration
export const putFile = async (
fileName: string,
fileBuffer: Buffer,
accessType: TAccessType,
environmentId: string
) => {
try {
if (!isS3Configured()) {
await putFileToLocalStorage(fileName, fileBuffer, accessType, environmentId, UPLOADS_DIR);
return { success: true, message: "File uploaded" };
} else {
const input = {
Body: fileBuffer,
Bucket: S3_BUCKET_NAME,
Key: `${environmentId}/${accessType}/${fileName}`,
};
const command = new PutObjectCommand(input);
const s3Client = getS3Client();
await s3Client.send(command);
return { success: true, message: "File uploaded" };
}
} catch (err) {
throw err;
}
};
export const deleteFile = async (
environmentId: string,
accessType: TAccessType,
fileName: string
): Promise<{ success: boolean; message: string; code?: number }> => {
if (!isS3Configured()) {
try {
await deleteLocalFile(path.join(UPLOADS_DIR, environmentId, accessType, fileName));
return { success: true, message: "File deleted" };
} catch (err: any) {
if (err.code !== "ENOENT") {
return { success: false, message: err.message ?? "Something went wrong" };
}
return { success: false, message: "File not found", code: 404 };
}
}
try {
await deleteS3File(`${environmentId}/${accessType}/${fileName}`);
return { success: true, message: "File deleted" };
} catch (err: any) {
if (err.name === "NoSuchKey") {
return { success: false, message: "File not found", code: 404 };
} else {
return { success: false, message: err.message ?? "Something went wrong" };
}
}
};
export const deleteLocalFile = async (filePath: string) => {
try {
const safeFilePath = validateAndResolvePath(filePath);
await unlink(safeFilePath);
} catch (err: any) {
throw err;
}
};
export const deleteS3File = async (fileKey: string) => {
const deleteObjectCommand = new DeleteObjectCommand({
Bucket: S3_BUCKET_NAME,
Key: fileKey,
});
try {
const s3Client = getS3Client();
await s3Client.send(deleteObjectCommand);
} catch (err) {
throw err;
}
};
export const deleteS3FilesByEnvironmentId = async (environmentId: string) => {
try {
// List all objects in the bucket with the prefix of environmentId
const s3Client = getS3Client();
const listObjectsOutput = await s3Client.send(
new ListObjectsCommand({
Bucket: S3_BUCKET_NAME,
Prefix: environmentId,
})
);
if (listObjectsOutput.Contents) {
const objectsToDelete = listObjectsOutput.Contents.map((obj) => {
return { Key: obj.Key };
});
if (!objectsToDelete.length) {
// no objects to delete
return null;
}
// Delete the objects
await s3Client.send(
new DeleteObjectsCommand({
Bucket: S3_BUCKET_NAME,
Delete: {
Objects: objectsToDelete,
},
})
);
} else {
// no objects to delete
return null;
}
} catch (err) {
throw err;
}
};
export const deleteLocalFilesByEnvironmentId = async (environmentId: string) => {
const dirPath = join(UPLOADS_DIR, environmentId);
try {
await ensureDirectoryExists(dirPath);
await rmdir(dirPath, { recursive: true });
} catch (err) {
throw err;
}
};
-42
View File
@@ -1,42 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { getFileNameWithIdFromUrl, getOriginalFileNameFromUrl } from "./utils";
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("Storage Utils", () => {
describe("getOriginalFileNameFromUrl", () => {
test("should handle URL without file ID", () => {
const url = "/storage/test-file.pdf";
expect(getOriginalFileNameFromUrl(url)).toBe("test-file.pdf");
});
test("should handle invalid URL", () => {
const url = "invalid-url";
expect(getOriginalFileNameFromUrl(url)).toBeUndefined();
expect(logger.error).toHaveBeenCalled();
});
});
describe("getFileNameWithIdFromUrl", () => {
test("should get full filename with ID from storage URL", () => {
const url = "/storage/test-file.pdf--fid--123";
expect(getFileNameWithIdFromUrl(url)).toBe("test-file.pdf--fid--123");
});
test("should get full filename with ID from external URL", () => {
const url = "https://example.com/path/test-file.pdf--fid--123";
expect(getFileNameWithIdFromUrl(url)).toBe("test-file.pdf--fid--123");
});
test("should handle invalid URL", () => {
const url = "invalid-url";
expect(getFileNameWithIdFromUrl(url)).toBeUndefined();
expect(logger.error).toHaveBeenCalled();
});
});
});
-35
View File
@@ -1,35 +0,0 @@
import { logger } from "@formbricks/logger";
export const getOriginalFileNameFromUrl = (fileURL: string) => {
try {
const fileNameFromURL = fileURL.startsWith("/storage/")
? fileURL.split("/").pop()
: new URL(fileURL).pathname.split("/").pop();
const fileExt = fileNameFromURL?.split(".").pop() ?? "";
const originalFileName = fileNameFromURL?.split("--fid--")[0] ?? "";
const fileId = fileNameFromURL?.split("--fid--")[1] ?? "";
if (!fileId) {
const fileName = originalFileName ? decodeURIComponent(originalFileName || "") : "";
return fileName;
}
const fileName = originalFileName ? decodeURIComponent(`${originalFileName}.${fileExt}` || "") : "";
return fileName;
} catch (error) {
logger.error(error, "Error parsing file URL");
}
};
export const getFileNameWithIdFromUrl = (fileURL: string) => {
try {
const fileNameFromURL = fileURL.startsWith("/storage/")
? fileURL.split("/").pop()
: new URL(fileURL).pathname.split("/").pop();
return fileNameFromURL ? decodeURIComponent(fileNameFromURL || "") : "";
} catch (error) {
logger.error(error, "Error parsing file URL");
}
};
+1 -1
View File
@@ -1,4 +1,4 @@
import * as fileValidation from "@/lib/fileValidation";
import * as fileValidation from "@/modules/storage/utils";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { InvalidInputError } from "@formbricks/types/errors";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
+1 -1
View File
@@ -1,5 +1,5 @@
import "server-only";
import { isValidImageFile } from "@/lib/fileValidation";
import { isValidImageFile } from "@/modules/storage/utils";
import { InvalidInputError } from "@formbricks/types/errors";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSegment } from "@formbricks/types/segment";
+6 -2
View File
@@ -15,12 +15,16 @@ export const convertToCsv = async (fields: string[], jsonData: Record<string, st
logger.error(err, "Failed to convert to CSV");
throw new Error("Failed to convert to CSV");
}
return csv;
};
export const convertToXlsxBuffer = (fields: string[], jsonData: Record<string, string | number>[]) => {
export const convertToXlsxBuffer = (
fields: string[],
jsonData: Record<string, string | number>[]
): Buffer => {
const wb = xlsx.utils.book_new();
const ws = xlsx.utils.json_to_sheet(jsonData, { header: fields });
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
return xlsx.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer;
return xlsx.write(wb, { type: "buffer", bookType: "xlsx" });
};
@@ -1,5 +1,5 @@
import { environmentId, fileUploadQuestion, openTextQuestion, responseData } from "./__mocks__/utils.mock";
import { deleteFile } from "@/lib/storage/service";
import { deleteFile } from "@/modules/storage/service";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { okVoid } from "@formbricks/types/error-handlers";
@@ -11,7 +11,7 @@ vi.mock("@formbricks/logger", () => ({
},
}));
vi.mock("@/lib/storage/service", () => ({
vi.mock("@/modules/storage/service", () => ({
deleteFile: vi.fn(),
}));
@@ -21,7 +21,7 @@ describe("findAndDeleteUploadedFilesInResponse", () => {
});
test("delete files for file upload questions and return okVoid", async () => {
vi.mocked(deleteFile).mockResolvedValue({ success: true, message: "File deleted successfully" });
vi.mocked(deleteFile).mockResolvedValue({ ok: true, data: undefined });
const result = await findAndDeleteUploadedFilesInResponse(responseData, [fileUploadQuestion]);
@@ -56,7 +56,7 @@ describe("findAndDeleteUploadedFilesInResponse", () => {
});
test("process multiple file URLs", async () => {
vi.mocked(deleteFile).mockResolvedValue({ success: true, message: "File deleted successfully" });
vi.mocked(deleteFile).mockResolvedValue({ ok: true, data: undefined });
const result = await findAndDeleteUploadedFilesInResponse(responseData, [fileUploadQuestion]);
@@ -1,5 +1,5 @@
import { deleteFile } from "@/lib/storage/service";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { deleteFile } from "@/modules/storage/service";
import { Response, Survey } from "@prisma/client";
import { logger } from "@formbricks/logger";
import { Result, okVoid } from "@formbricks/types/error-handlers";
@@ -1,4 +1,3 @@
import { validateFileUploads } from "@/lib/fileValidation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { responses } from "@/modules/api/v2/lib/response";
@@ -12,6 +11,7 @@ import {
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { z } from "zod";
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
@@ -1,4 +1,3 @@
import { validateFileUploads } from "@/lib/fileValidation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { responses } from "@/modules/api/v2/lib/response";
@@ -8,6 +7,7 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { Response } from "@prisma/client";
import { NextRequest } from "next/server";
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
@@ -10,12 +10,8 @@ export const FormWrapper = ({ children }: FormWrapperProps) => {
<div className="mx-auto flex flex-1 flex-col justify-center px-4 py-12 sm:px-6 lg:flex-none lg:px-20 xl:px-24">
<div className="mx-auto w-full max-w-sm rounded-xl bg-white p-8 shadow-xl lg:w-96">
<div className="mb-8 text-center">
<Link
target="_blank"
href="https://formbricks.com?utm_source=ce"
rel="noopener noreferrer"
aria-label="Formbricks website">
<Logo className="mx-auto w-3/4" variant="wordmark" aria-hidden="true" />
<Link target="_blank" href="https://formbricks.com?utm_source=ce" rel="noopener noreferrer">
<Logo className="mx-auto w-3/4" />
</Link>
</div>
{children}
@@ -134,6 +134,8 @@ describe("rateLimitConfigs", () => {
{ config: rateLimitConfigs.api.client, identifier: "client-api-key" },
{ config: rateLimitConfigs.api.syncUserIdentification, identifier: "sync-user-id" },
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
{ config: rateLimitConfigs.storage.upload, identifier: "storage-upload" },
{ config: rateLimitConfigs.storage.delete, identifier: "storage-delete" },
];
for (const { config, identifier } of testCases) {
@@ -177,5 +179,23 @@ describe("rateLimitConfigs", () => {
expect(exceededResult.data.allowed).toBe(false);
}
});
test("should properly configure storage upload rate limit", async () => {
const config = rateLimitConfigs.storage.upload;
// Verify configuration values
expect(config.interval).toBe(60); // 1 minute
expect(config.allowedPerInterval).toBe(5); // 5 requests per minute
expect(config.namespace).toBe("storage:upload");
});
test("should properly configure storage delete rate limit", async () => {
const config = rateLimitConfigs.storage.delete;
// Verify configuration values
expect(config.interval).toBe(60); // 1 minute
expect(config.allowedPerInterval).toBe(5); // 5 requests per minute
expect(config.namespace).toBe("storage:delete");
});
});
});
@@ -29,4 +29,9 @@ export const rateLimitConfigs = {
namespace: "action:send-link-survey-email",
}, // 10 per hour
},
storage: {
upload: { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" }, // 5 per minute
delete: { interval: 60, allowedPerInterval: 5, namespace: "storage:delete" }, // 5 per minute
},
};
@@ -255,11 +255,11 @@ export const UploadContactsCSVButton = ({
const headers = Object.keys(exampleData[0]);
const csvRows = [headers.join(","), ...exampleData.map((row) => headers.map((h) => row[h]).join(","))];
const csvContent = "data:text/csv;charset=utf-8," + csvRows.join("\n");
const encodedUri = encodeURI(csvContent);
const csvString = csvRows.join("\n");
const csvContent = "data:text/csv;charset=utf-8," + encodeURIComponent(csvString);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("href", csvContent);
link.setAttribute("download", "example.csv");
document.body.appendChild(link); // Required for Firefox
link.click();
@@ -1,9 +1,9 @@
import { handleFileUpload } from "@/app/lib/fileUpload";
import {
removeOrganizationEmailLogoUrlAction,
sendTestEmailAction,
updateOrganizationEmailLogoUrlAction,
} from "@/modules/ee/whitelabel/email-customization/actions";
import { handleFileUpload } from "@/modules/storage/file-upload";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@@ -12,13 +12,17 @@ import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { EmailCustomizationSettings } from "./email-customization-settings";
vi.mock("@/lib/constants", () => ({
IS_STORAGE_CONFIGURED: true,
}));
vi.mock("@/modules/ee/whitelabel/email-customization/actions", () => ({
removeOrganizationEmailLogoUrlAction: vi.fn(),
sendTestEmailAction: vi.fn(),
updateOrganizationEmailLogoUrlAction: vi.fn(),
}));
vi.mock("@/app/lib/fileUpload", () => ({
vi.mock("@/modules/storage/file-upload", () => ({
handleFileUpload: vi.fn(),
}));
@@ -41,6 +45,7 @@ const defaultProps = {
name: "Test User",
} as TUser,
fbLogoUrl: "https://example.com/fallback-logo.png",
isStorageConfigured: true,
};
describe("EmailCustomizationSettings", () => {
@@ -1,7 +1,6 @@
"use client";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { handleFileUpload } from "@/app/lib/fileUpload";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
@@ -9,9 +8,11 @@ import {
sendTestEmailAction,
updateOrganizationEmailLogoUrlAction,
} from "@/modules/ee/whitelabel/email-customization/actions";
import { handleFileUpload } from "@/modules/storage/file-upload";
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";
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
import { Muted, P, Small } from "@/modules/ui/components/typography";
import { ModalButton, UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { useTranslate } from "@tolgee/react";
@@ -20,8 +21,8 @@ import Image from "next/image";
import { useRouter } from "next/navigation";
import React, { useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { TAllowedFileExtension } from "@formbricks/types/common";
import { TOrganization } from "@formbricks/types/organizations";
import { TAllowedFileExtension } from "@formbricks/types/storage";
import { TUser } from "@formbricks/types/user";
const allowedFileExtensions: TAllowedFileExtension[] = ["jpeg", "png", "jpg", "webp"];
@@ -34,6 +35,7 @@ interface EmailCustomizationSettingsProps {
isFormbricksCloud: boolean;
user: TUser | null;
fbLogoUrl: string;
isStorageConfigured: boolean;
}
export const EmailCustomizationSettings = ({
@@ -44,6 +46,7 @@ export const EmailCustomizationSettings = ({
isFormbricksCloud,
user,
fbLogoUrl,
isStorageConfigured,
}: EmailCustomizationSettingsProps) => {
const { t } = useTranslate();
@@ -57,10 +60,15 @@ export const EmailCustomizationSettings = ({
const router = useRouter();
const onFileInputChange = (files: File[]) => {
if (!isStorageConfigured) {
showStorageNotConfiguredToast();
return;
}
const file = files[0];
if (!file) return;
// Revoke any previous object URL so we dont leak memory
// Revoke any previous object URL so we don't leak memory
if (logoUrl) {
URL.revokeObjectURL(logoUrl);
}
@@ -79,6 +87,11 @@ export const EmailCustomizationSettings = ({
e.preventDefault();
e.stopPropagation();
if (!isStorageConfigured) {
showStorageNotConfiguredToast();
return;
}
const files = Array.from(e.dataTransfer.files);
const file = files[0];
if (!file) return;
@@ -210,7 +223,13 @@ export const EmailCustomizationSettings = ({
<Button
data-testid="replace-logo-button"
variant="secondary"
onClick={() => inputRef.current?.click()}
onClick={() => {
if (!isStorageConfigured) {
showStorageNotConfiguredToast();
return;
}
inputRef.current?.click();
}}
disabled={isReadOnly || isSaving}>
<RepeatIcon className="h-4 w-4" />
{t("environments.settings.general.replace_logo")}
@@ -240,6 +259,7 @@ export const EmailCustomizationSettings = ({
multiple={false}
handleUpload={onFileInputChange}
disabled={isReadOnly}
isStorageConfigured={isStorageConfigured}
/>
</div>
@@ -15,7 +15,7 @@ vi.mock("@react-email/components", () => ({
}));
// Mock dependencies
vi.mock("@/lib/storage/utils", () => ({
vi.mock("@/modules/storage/utils", () => ({
getOriginalFileNameFromUrl: (url: string) => {
// Extract filename from the URL for testing purposes
const parts = url.split("/");
@@ -31,8 +31,8 @@ describe("renderEmailResponseValue", () => {
test("renders clickable file upload links with file icons and truncated file names when overrideFileUploadResponse is false", async () => {
// Arrange
const fileUrls = [
"https://example.com/uploads/file1.pdf",
"https://example.com/uploads/very-long-filename-that-should-be-truncated.docx",
"https://example.com/files/file1.pdf",
"https://example.com/files/very-long-filename-that-should-be-truncated.docx",
];
// Act
@@ -65,7 +65,7 @@ describe("renderEmailResponseValue", () => {
test("renders a message when overrideFileUploadResponse is true", async () => {
// Arrange
const fileUrls = ["https://example.com/uploads/file1.pdf"];
const fileUrls = ["https://example.com/files/file1.pdf"];
const expectedMessage = "emails.render_email_response_value_file_upload_response_link_not_included";
// Act
+1 -1
View File
@@ -1,4 +1,4 @@
import { getOriginalFileNameFromUrl } from "@/lib/storage/utils";
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
import { Column, Container, Img, Link, Row, Text } from "@react-email/components";
import { TFnType } from "@tolgee/react";
import { FileIcon } from "lucide-react";
@@ -149,10 +149,10 @@ describe("AddApiKeyModal", () => {
test("handles label input", async () => {
render(<AddApiKeyModal {...defaultProps} />);
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack");
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack") as HTMLInputElement;
await userEvent.type(labelInput, "Test API Key");
expect((labelInput as HTMLInputElement).value).toBe("Test API Key");
expect(labelInput.value).toBe("Test API Key");
});
test("handles permission changes", async () => {
@@ -184,120 +184,21 @@ describe("AddApiKeyModal", () => {
await userEvent.click(addButton);
// Verify new permission row is added
const deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
const deleteButtons = screen.getAllByRole("button", { name: "" }); // Trash icons
expect(deleteButtons).toHaveLength(2);
// Remove the new permission
await userEvent.click(deleteButtons[1]);
// Check that only the original permission row remains
const remainingDeleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(remainingDeleteButtons).toHaveLength(1);
});
test("removes permissions from middle of list without breaking indices", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Add first permission
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Add second permission
await userEvent.click(addButton);
// Add third permission
await userEvent.click(addButton);
// Verify we have 3 permission rows
let deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(deleteButtons).toHaveLength(3);
// Remove the middle permission (index 1)
await userEvent.click(deleteButtons[1]);
// Verify we now have 2 permission rows
deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(deleteButtons).toHaveLength(2);
// Try to remove the second remaining permission (this was previously index 2, now index 1)
await userEvent.click(deleteButtons[1]);
// Verify we now have 1 permission row
deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(deleteButtons).toHaveLength(1);
// Remove the last remaining permission
await userEvent.click(deleteButtons[0]);
// Verify no permission rows remain
expect(
screen.queryAllByRole("button", { name: "environments.project.api_keys.delete_permission" })
).toHaveLength(0);
});
test("can modify permissions after deleting items from list", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Add multiple permissions
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton); // First permission
await userEvent.click(addButton); // Second permission
await userEvent.click(addButton); // Third permission
// Verify we have 3 permission rows
let deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(deleteButtons).toHaveLength(3);
// Remove the first permission (index 0)
await userEvent.click(deleteButtons[0]);
// Verify we now have 2 permission rows
deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(deleteButtons).toHaveLength(2);
// Try to modify the first remaining permission (which was originally index 1, now index 0)
const projectDropdowns = screen.getAllByRole("button", { name: /Project 1/i });
expect(projectDropdowns.length).toBeGreaterThan(0);
await userEvent.click(projectDropdowns[0]);
// Wait for dropdown content and select 'Project 2'
const project2Option = await screen.findByRole("menuitem", { name: "Project 2" });
await userEvent.click(project2Option);
// Verify project selection by checking the updated button text
const updatedButton = await screen.findByRole("button", { name: "Project 2" });
expect(updatedButton).toBeInTheDocument();
// Add another permission to verify the list is still functional
await userEvent.click(addButton);
// Verify we now have 3 permission rows again
deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(deleteButtons).toHaveLength(3);
expect(screen.getAllByRole("button", { name: "" })).toHaveLength(1);
});
test("submits form with correct data", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Fill in label
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack");
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack") as HTMLInputElement;
await userEvent.type(labelInput, "Test API Key");
const addButton = screen.getByRole("button", { name: /add_permission/i });
@@ -377,7 +278,7 @@ describe("AddApiKeyModal", () => {
render(<AddApiKeyModal {...defaultProps} />);
// Type something into the label
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack");
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack") as HTMLInputElement;
await userEvent.type(labelInput, "Test API Key");
// Click the cancel button
@@ -386,219 +287,6 @@ describe("AddApiKeyModal", () => {
// Verify modal is closed and form is reset
expect(mockSetOpen).toHaveBeenCalledWith(false);
expect((labelInput as HTMLInputElement).value).toBe("");
});
test("updates permission field (non-environmentId)", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Add a permission first
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Click on permission level dropdown (third dropdown in the row)
const permissionDropdowns = screen.getAllByRole("button", { name: /read/i });
await userEvent.click(permissionDropdowns[0]);
// Select 'write' permission
const writeOption = await screen.findByRole("menuitem", { name: "write" });
await userEvent.click(writeOption);
// Verify permission selection by checking the updated button text
const updatedButton = await screen.findByRole("button", { name: "write" });
expect(updatedButton).toBeInTheDocument();
});
test("updates environmentId with valid environment", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Add a permission first
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Click on environment dropdown (second dropdown in the row)
const environmentDropdowns = screen.getAllByRole("button", { name: /production/i });
await userEvent.click(environmentDropdowns[0]);
// Select 'development' environment
const developmentOption = await screen.findByRole("menuitem", { name: "development" });
await userEvent.click(developmentOption);
// Verify environment selection by checking the updated button text
const updatedButton = await screen.findByRole("button", { name: "development" });
expect(updatedButton).toBeInTheDocument();
});
test("updates project and automatically selects first environment", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Add a permission first
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Initially should show Project 1 and production environment
expect(screen.getByRole("button", { name: "Project 1" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /production/i })).toBeInTheDocument();
// Click on project dropdown (first dropdown in the row)
const projectDropdowns = screen.getAllByRole("button", { name: /Project 1/i });
await userEvent.click(projectDropdowns[0]);
// Select 'Project 2'
const project2Option = await screen.findByRole("menuitem", { name: "Project 2" });
await userEvent.click(project2Option);
// Verify project selection and that environment was auto-updated
const updatedProjectButton = await screen.findByRole("button", { name: "Project 2" });
expect(updatedProjectButton).toBeInTheDocument();
// Environment should still be production (first environment of Project 2)
expect(screen.getByRole("button", { name: /production/i })).toBeInTheDocument();
});
test("handles edge case when project is not found", async () => {
// Create a modified mock with corrupted project reference
const corruptedProjects = [
{
...mockProjects[0],
id: "different-id", // This will cause project lookup to fail
},
];
render(<AddApiKeyModal {...defaultProps} projects={corruptedProjects} />);
// Add a permission first
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// The component should still render without crashing
expect(screen.getByRole("button", { name: /add_permission/i })).toBeInTheDocument();
// Try to interact with environment dropdown - should not crash
const environmentDropdowns = screen.getAllByRole("button", { name: /production/i });
await userEvent.click(environmentDropdowns[0]);
// Should be able to find and click on development option
const developmentOption = await screen.findByRole("menuitem", { name: "development" });
await userEvent.click(developmentOption);
// Verify environment selection works even when project lookup fails
const updatedButton = await screen.findByRole("button", { name: "development" });
expect(updatedButton).toBeInTheDocument();
});
test("handles edge case when environment is not found", async () => {
// Create a project with no environments
const projectWithNoEnvs = [
{
...mockProjects[0],
environments: [], // No environments available
},
];
render(<AddApiKeyModal {...defaultProps} projects={projectWithNoEnvs} />);
// Try to add a permission - this should handle the case gracefully
const addButton = screen.getByRole("button", { name: /add_permission/i });
// This might not add a permission if no environments exist, which is expected behavior
await userEvent.click(addButton);
// Component should still be functional
expect(screen.getByRole("button", { name: /add_permission/i })).toBeInTheDocument();
});
test("validates duplicate permissions detection", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Fill in a label
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack");
await userEvent.type(labelInput, "Test API Key");
// Add first permission
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Add second permission with same project/environment
await userEvent.click(addButton);
// Both permissions should now have the same project and environment (Project 1, production)
// Try to submit the form - it should show duplicate error
const submitButton = screen.getByRole("button", {
name: "environments.project.api_keys.add_api_key",
});
await userEvent.click(submitButton);
// The submit should not have been called due to duplicate detection
expect(mockOnSubmit).not.toHaveBeenCalled();
});
test("handles updatePermission with environmentId but environment not found", async () => {
// Create a project with limited environments to test the edge case
const limitedProjects = [
{
...mockProjects[0],
environments: [
{
id: "env1",
type: "production" as const,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "project1",
appSetupCompleted: true,
},
// Only one environment, so we can test when trying to update to non-existent env
],
},
];
render(<AddApiKeyModal {...defaultProps} projects={limitedProjects} />);
// Add a permission first
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Verify permission was added with production environment
expect(screen.getByRole("button", { name: /production/i })).toBeInTheDocument();
// Now test the edge case by manually calling the component's internal logic
// Since we can't directly access the updatePermission function in tests,
// we test through the UI interactions and verify the component doesn't crash
// The component should handle gracefully when environment lookup fails
// This tests the branch: field === "environmentId" && !environment
expect(screen.getByRole("button", { name: /production/i })).toBeInTheDocument();
});
test("covers all branches of updatePermission function", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Add a permission to have something to update
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Test Branch 1: Update non-environmentId field (permission level)
const permissionDropdowns = screen.getAllByRole("button", { name: /read/i });
await userEvent.click(permissionDropdowns[0]);
const manageOption = await screen.findByRole("menuitem", { name: "manage" });
await userEvent.click(manageOption);
expect(await screen.findByRole("button", { name: "manage" })).toBeInTheDocument();
// Test Branch 2: Update environmentId with valid environment
const environmentDropdowns = screen.getAllByRole("button", { name: /production/i });
await userEvent.click(environmentDropdowns[0]);
const developmentOption = await screen.findByRole("menuitem", { name: "development" });
await userEvent.click(developmentOption);
expect(await screen.findByRole("button", { name: "development" })).toBeInTheDocument();
// Test Branch 3: Update project (which calls updateProjectAndEnvironment)
const projectDropdowns = screen.getAllByRole("button", { name: /Project 1/i });
await userEvent.click(projectDropdowns[0]);
const project2Option = await screen.findByRole("menuitem", { name: "Project 2" });
await userEvent.click(project2Option);
expect(await screen.findByRole("button", { name: "Project 2" })).toBeInTheDocument();
// Verify all updates worked correctly and component is still functional
expect(screen.getByRole("button", { name: /add_permission/i })).toBeInTheDocument();
expect(labelInput.value).toBe("");
});
});
@@ -80,22 +80,23 @@ export const AddApiKeyModal = ({
const [selectedOrganizationAccess, setSelectedOrganizationAccess] =
useState<TOrganizationAccess>(defaultOrganizationAccess);
const getInitialPermissions = (): PermissionRecord[] => {
const getInitialPermissions = () => {
if (projects.length > 0 && projects[0].environments.length > 0) {
return [
{
return {
"permission-0": {
projectId: projects[0].id,
environmentId: projects[0].environments[0].id,
permission: ApiKeyPermission.read,
projectName: projects[0].name,
environmentType: projects[0].environments[0].type,
},
];
};
}
return [];
return {} as Record<string, PermissionRecord>;
};
const [selectedPermissions, setSelectedPermissions] = useState<PermissionRecord[]>([]);
// Initialize with one permission by default
const [selectedPermissions, setSelectedPermissions] = useState<Record<string, PermissionRecord>>({});
const projectOptions: ProjectOption[] = projects.map((project) => ({
id: project.id,
@@ -103,54 +104,58 @@ export const AddApiKeyModal = ({
}));
const removePermission = (index: number) => {
const updatedPermissions = [...selectedPermissions];
updatedPermissions.splice(index, 1);
const updatedPermissions = { ...selectedPermissions };
delete updatedPermissions[`permission-${index}`];
setSelectedPermissions(updatedPermissions);
};
const addPermission = () => {
const initialPermissions = getInitialPermissions();
if (initialPermissions.length > 0) {
setSelectedPermissions([...selectedPermissions, initialPermissions[0]]);
const newIndex = Object.keys(selectedPermissions).length;
const initialPermission = getInitialPermissions()["permission-0"];
if (initialPermission) {
setSelectedPermissions({
...selectedPermissions,
[`permission-${newIndex}`]: initialPermission,
});
}
};
const updatePermission = (index: number, field: string, value: string) => {
const updatedPermissions = [...selectedPermissions];
const project = projects.find((p) => p.id === updatedPermissions[index].projectId);
const updatePermission = (key: string, field: string, value: string) => {
const project = projects.find((p) => p.id === selectedPermissions[key].projectId);
const environment = project?.environments.find((env) => env.id === value);
updatedPermissions[index] = {
...updatedPermissions[index],
[field]: value,
...(field === "environmentId" && environment ? { environmentType: environment.type } : {}),
};
setSelectedPermissions(updatedPermissions);
setSelectedPermissions({
...selectedPermissions,
[key]: {
...selectedPermissions[key],
[field]: value,
...(field === "environmentId" && environment ? { environmentType: environment.type } : {}),
},
});
};
// Update environment when project changes
const updateProjectAndEnvironment = (index: number, projectId: string) => {
const updateProjectAndEnvironment = (key: string, projectId: string) => {
const project = projects.find((p) => p.id === projectId);
if (project && project.environments.length > 0) {
const environment = project.environments[0];
const updatedPermissions = [...selectedPermissions];
updatedPermissions[index] = {
...updatedPermissions[index],
projectId,
environmentId: environment.id,
projectName: project.name,
environmentType: environment.type,
};
setSelectedPermissions(updatedPermissions);
setSelectedPermissions({
...selectedPermissions,
[key]: {
...selectedPermissions[key],
projectId,
environmentId: environment.id,
projectName: project.name,
environmentType: environment.type,
},
});
}
};
const checkForDuplicatePermissions = () => {
const uniquePermissions = new Set(selectedPermissions.map((p) => `${p.projectId}-${p.environmentId}`));
return uniquePermissions.size !== selectedPermissions.length;
const permissions = Object.values(selectedPermissions);
const uniquePermissions = new Set(permissions.map((p) => `${p.projectId}-${p.environmentId}`));
return uniquePermissions.size !== permissions.length;
};
const submitAPIKey = async () => {
@@ -162,7 +167,7 @@ export const AddApiKeyModal = ({
}
// Convert permissions to the format expected by the API
const environmentPermissions = selectedPermissions.map((permission) => ({
const environmentPermissions = Object.values(selectedPermissions).map((permission) => ({
environmentId: permission.environmentId,
permission: permission.permission,
}));
@@ -174,7 +179,7 @@ export const AddApiKeyModal = ({
});
reset();
setSelectedPermissions([]);
setSelectedPermissions({});
setSelectedOrganizationAccess(defaultOrganizationAccess);
};
@@ -191,7 +196,7 @@ export const AddApiKeyModal = ({
}
// Check if at least one project permission is set or one organization access toggle is ON
const hasProjectAccess = selectedPermissions.length > 0;
const hasProjectAccess = Object.keys(selectedPermissions).length > 0;
const hasOrganizationAccess = Object.values(selectedOrganizationAccess).some((accessGroup) =>
Object.values(accessGroup).some((value) => value === true)
@@ -230,9 +235,13 @@ export const AddApiKeyModal = ({
<div className="space-y-2">
<Label>{t("environments.project.api_keys.project_access")}</Label>
<div className="space-y-2">
{selectedPermissions.map((permission, index) => {
{/* Permission rows */}
{Object.keys(selectedPermissions).map((key) => {
const permissionIndex = parseInt(key.split("-")[1]);
const permission = selectedPermissions[key];
return (
<div key={index + permission.projectId} className="flex items-center gap-2">
<div key={key} className="flex items-center gap-2">
{/* Project dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -252,7 +261,7 @@ export const AddApiKeyModal = ({
<DropdownMenuItem
key={option.id}
onClick={() => {
updateProjectAndEnvironment(index, option.id);
updateProjectAndEnvironment(key, option.id);
}}>
{option.name}
</DropdownMenuItem>
@@ -260,6 +269,8 @@ export const AddApiKeyModal = ({
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Environment dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -281,7 +292,7 @@ export const AddApiKeyModal = ({
<DropdownMenuItem
key={env.id}
onClick={() => {
updatePermission(index, "environmentId", env.id);
updatePermission(key, "environmentId", env.id);
}}>
{env.type}
</DropdownMenuItem>
@@ -289,6 +300,8 @@ export const AddApiKeyModal = ({
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Permission level dropdown */}
<div className="w-1/3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -310,7 +323,7 @@ export const AddApiKeyModal = ({
<DropdownMenuItem
key={option}
onClick={() => {
updatePermission(index, "permission", option);
updatePermission(key, "permission", option);
}}>
{option}
</DropdownMenuItem>
@@ -318,16 +331,16 @@ export const AddApiKeyModal = ({
</DropdownMenuContent>
</DropdownMenu>
</div>
<button
type="button"
className="p-2"
onClick={() => removePermission(index)}
aria-label={t("environments.project.api_keys.delete_permission")}>
{/* Delete button */}
<button type="button" className="p-2" onClick={() => removePermission(permissionIndex)}>
<Trash2Icon className={"h-5 w-5 text-slate-500 hover:text-red-500"} />
</button>
</div>
);
})}
{/* Add permission button */}
<Button type="button" variant="outline" onClick={addPermission}>
<span className="mr-2">+</span> {t("environments.settings.api_keys.add_permission")}
</Button>
@@ -384,7 +397,7 @@ export const AddApiKeyModal = ({
onClick={() => {
setOpen(false);
reset();
setSelectedPermissions([]);
setSelectedPermissions({});
}}>
{t("common.cancel")}
</Button>
@@ -128,6 +128,7 @@ describe("OrganizationActions Component", () => {
environmentId: "env-123",
isMultiOrgEnabled: true,
isUserManagementDisabledFromUi: false,
isStorageConfigured: true,
};
beforeEach(() => {
@@ -36,6 +36,7 @@ interface OrganizationActionsProps {
environmentId: string;
isMultiOrgEnabled: boolean;
isUserManagementDisabledFromUi: boolean;
isStorageConfigured: boolean;
}
export const OrganizationActions = ({
@@ -50,6 +51,7 @@ export const OrganizationActions = ({
environmentId,
isMultiOrgEnabled,
isUserManagementDisabledFromUi,
isStorageConfigured,
}: OrganizationActionsProps) => {
const router = useRouter();
const { t } = useTranslate();
@@ -158,6 +160,7 @@ export const OrganizationActions = ({
isFormbricksCloud={isFormbricksCloud}
environmentId={environmentId}
teams={teams}
isStorageConfigured={isStorageConfigured}
/>
<Dialog open={isLeaveOrganizationModalOpen} onOpenChange={setLeaveOrganizationModalOpen}>
@@ -0,0 +1,197 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { BulkInviteTab } from "./bulk-invite-tab";
// Hoisted fns for mocks to avoid hoisting pitfalls
const h = vi.hoisted(() => ({
mockParse: vi.fn(),
mockToastError: vi.fn(),
}));
// Mocks
vi.mock("papaparse", () => ({
default: { parse: h.mockParse },
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (k: string) => k }),
}));
vi.mock("react-hot-toast", () => ({
default: { error: h.mockToastError },
}));
vi.mock("@/modules/organization/settings/teams/types/invites", () => ({
ZInvitees: { parse: vi.fn() },
}));
let lastUploaderProps: any;
vi.mock("@/modules/ui/components/file-input/components/uploader", () => ({
Uploader: vi.fn((props: any) => {
lastUploaderProps = props;
return (
<div data-testid="uploader-mock">
<input data-testid="upload-file-input" />
</div>
);
}),
}));
describe("BulkInviteTab", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
lastUploaderProps = undefined;
});
const baseProps = {
setOpen: vi.fn(),
onSubmit: vi.fn(),
isAccessControlAllowed: true,
isFormbricksCloud: true,
isStorageConfigured: true,
};
test("renders Uploader with correct props", () => {
render(<BulkInviteTab {...baseProps} />);
expect(screen.getByTestId("uploader-mock")).toBeInTheDocument();
expect(lastUploaderProps).toEqual(
expect.objectContaining({
allowedFileExtensions: ["csv"],
id: "bulk-invite",
name: "bulk-invite",
multiple: false,
disabled: false,
isStorageConfigured: true,
})
);
});
test("selecting a CSV shows filename, disables uploader, enables import; removing clears it", async () => {
render(<BulkInviteTab {...baseProps} />);
const user = userEvent.setup();
const file = new File(["name,email,role\nA,a@example.com,manager"], "people.csv", {
type: "text/csv",
});
// Simulate upload via mocked Uploader
lastUploaderProps.handleUpload([file]);
// Filename visible
expect(await screen.findByText("people.csv")).toBeInTheDocument();
// Uploader should be disabled after selection (component re-renders)
await waitFor(() => expect(lastUploaderProps.disabled).toBe(true));
// Import button enabled
const importButton = screen.getByRole("button", { name: /common.import/i });
expect(importButton).toBeEnabled();
// Remove file (icon-only button near filename)
const nameEl = screen.getByText("people.csv");
const container = nameEl.closest("div") as HTMLElement;
const removeBtn = within(container).getByRole("button");
await user.click(removeBtn);
expect(screen.queryByText("people.csv")).not.toBeInTheDocument();
});
test("onImport parses CSV and calls onSubmit (access control allowed)", async () => {
render(<BulkInviteTab {...baseProps} />);
const file = new File(["dummy"], "people.csv", { type: "text/csv" });
lastUploaderProps.handleUpload([file]);
// Mock Papa.parse to synchronously call complete with parsed rows
h.mockParse.mockImplementation((_file: File, opts: any) => {
opts.complete({
data: [
{ name: " Alice ", email: " alice@example.com ", role: " Manager " },
{ name: "Bob", email: "bob@example.com", role: "member" },
],
});
});
const importButton = screen.getByRole("button", { name: /common.import/i });
await userEvent.click(importButton);
expect(baseProps.onSubmit).toHaveBeenCalledWith([
{ name: "Alice", email: "alice@example.com", role: "manager", teamIds: [] },
{ name: "Bob", email: "bob@example.com", role: "member", teamIds: [] },
]);
expect(baseProps.setOpen).toHaveBeenCalledWith(false);
});
test("onImport forces owner when access control not allowed", async () => {
const props = { ...baseProps, isAccessControlAllowed: false };
render(<BulkInviteTab {...props} />);
const file = new File(["dummy"], "people.csv", { type: "text/csv" });
lastUploaderProps.handleUpload([file]);
h.mockParse.mockImplementation((_file: File, opts: any) => {
opts.complete({
data: [{ name: "Carol", email: "carol@example.com", role: "admin" }],
});
});
const importButton = screen.getByRole("button", { name: /common.import/i });
await userEvent.click(importButton);
expect(props.onSubmit).toHaveBeenCalledWith([
{ name: "Carol", email: "carol@example.com", role: "owner", teamIds: [] },
]);
});
test("onImport maps billing to owner when not Formbricks Cloud", async () => {
const props = { ...baseProps, isFormbricksCloud: false };
render(<BulkInviteTab {...props} />);
const file = new File(["dummy"], "people.csv", { type: "text/csv" });
lastUploaderProps.handleUpload([file]);
h.mockParse.mockImplementation((_file: File, opts: any) => {
opts.complete({
data: [{ name: "Dave", email: "dave@example.com", role: "billing" }],
});
});
const importButton = screen.getByRole("button", { name: /common.import/i });
await userEvent.click(importButton);
expect(props.onSubmit).toHaveBeenCalledWith([
{ name: "Dave", email: "dave@example.com", role: "owner", teamIds: [] },
]);
});
test("invalid drop file type shows toast error", async () => {
render(<BulkInviteTab {...baseProps} />);
// Call handleDrop with a non-csv file
const evt = {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: { files: [new File(["x"], "image.png", { type: "image/png" })] },
} as any;
await lastUploaderProps.handleDrop(evt);
expect(h.mockToastError).toHaveBeenCalled();
});
test("remove file button clears selection", async () => {
render(<BulkInviteTab {...baseProps} />);
const user = userEvent.setup();
const file = new File(["x"], "people.csv", { type: "text/csv" });
lastUploaderProps.handleUpload([file]);
// Locate the container that shows filename and its button
const nameEl = await screen.findByText("people.csv");
const container = nameEl.closest("div") as HTMLElement;
const removeButton = within(container).getByRole("button");
await user.click(removeButton);
expect(screen.queryByText("people.csv")).not.toBeInTheDocument();
});
});
@@ -17,6 +17,7 @@ interface BulkInviteTabProps {
onSubmit: (data: { name: string; email: string; role: TOrganizationRole }[]) => void;
isAccessControlAllowed: boolean;
isFormbricksCloud: boolean;
isStorageConfigured: boolean;
}
export const BulkInviteTab = ({
@@ -24,6 +25,7 @@ export const BulkInviteTab = ({
onSubmit,
isAccessControlAllowed,
isFormbricksCloud,
isStorageConfigured,
}: BulkInviteTabProps) => {
const { t } = useTranslate();
const [csvFile, setCSVFile] = useState<File>();
@@ -108,6 +110,7 @@ export const BulkInviteTab = ({
name="bulk-invite"
disabled={csvFile !== undefined}
uploaderClassName="h-20 bg-white border border-slate-200"
isStorageConfigured={isStorageConfigured}
/>
{csvFile && (
@@ -59,6 +59,7 @@ const defaultProps = {
isFormbricksCloud: true,
environmentId: "env-1",
membershipRole: "owner" as TOrganizationRole,
isStorageConfigured: true,
};
describe("InviteMemberModal", () => {
@@ -25,6 +25,7 @@ interface InviteMemberModalProps {
isFormbricksCloud: boolean;
environmentId: string;
membershipRole?: TOrganizationRole;
isStorageConfigured: boolean;
}
export const InviteMemberModal = ({
@@ -36,6 +37,7 @@ export const InviteMemberModal = ({
isFormbricksCloud,
environmentId,
membershipRole,
isStorageConfigured,
}: InviteMemberModalProps) => {
const [type, setType] = useState<"individual" | "bulk">("individual");
@@ -59,6 +61,7 @@ export const InviteMemberModal = ({
onSubmit={onSubmit}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={isFormbricksCloud}
isStorageConfigured={isStorageConfigured}
/>
),
};
@@ -20,6 +20,7 @@ vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCa
vi.mock("@/lib/constants", () => ({
INVITE_DISABLED: false,
IS_FORMBRICKS_CLOUD: true,
IS_STORAGE_CONFIGURED: true,
}));
vi.mock("@/modules/organization/settings/teams/components/edit-memberships/organization-actions", () => ({
@@ -1,5 +1,5 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
@@ -64,6 +64,7 @@ export const MembersView = async ({
isInviteDisabled={INVITE_DISABLED}
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isStorageConfigured={IS_STORAGE_CONFIGURED}
environmentId={environmentId}
isMultiOrgEnabled={isMultiOrgEnabled}
teams={teams}
@@ -1,9 +1,10 @@
import { createEnvironment } from "@/lib/environment/service";
import { deleteLocalFilesByEnvironmentId, deleteS3FilesByEnvironmentId } from "@/lib/storage/service";
import { deleteFilesByEnvironmentId } from "@/modules/storage/service";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { StorageErrorCode } from "@formbricks/storage";
import { TEnvironment } from "@formbricks/types/environment";
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
import { ZProject } from "@formbricks/types/project";
@@ -64,22 +65,14 @@ vi.mock("@formbricks/logger", () => ({
},
}));
vi.mock("@/lib/storage/service", () => ({
deleteLocalFilesByEnvironmentId: vi.fn(),
deleteS3FilesByEnvironmentId: vi.fn(),
vi.mock("@/modules/storage/service", () => ({
deleteFilesByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/environment/service", () => ({
createEnvironment: vi.fn(),
}));
let mockIsS3Configured = true;
vi.mock("@/lib/constants", () => ({
isS3Configured: () => {
return mockIsS3Configured;
},
}));
describe("project lib", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -156,28 +149,21 @@ describe("project lib", () => {
});
describe("deleteProject", () => {
test("deletes project, deletes files, and revalidates cache (S3)", async () => {
test("deletes project, deletes files, and revalidates cache", async () => {
vi.mocked(prisma.project.delete).mockResolvedValueOnce(baseProject as any);
vi.mocked(deleteS3FilesByEnvironmentId).mockResolvedValue(undefined);
vi.mocked(deleteFilesByEnvironmentId).mockResolvedValue({ ok: true, data: undefined });
const result = await deleteProject("p1");
expect(result).toEqual(baseProject);
expect(deleteS3FilesByEnvironmentId).toHaveBeenCalledWith("prodenv");
});
test("deletes project, deletes files, and revalidates cache (local)", async () => {
vi.mocked(prisma.project.delete).mockResolvedValueOnce(baseProject as any);
mockIsS3Configured = false;
vi.mocked(deleteLocalFilesByEnvironmentId).mockResolvedValue(undefined);
const result = await deleteProject("p1");
expect(result).toEqual(baseProject);
expect(deleteLocalFilesByEnvironmentId).toHaveBeenCalledWith("prodenv");
expect(deleteFilesByEnvironmentId).toHaveBeenCalledWith("prodenv");
});
test("logs error if file deletion fails", async () => {
vi.mocked(prisma.project.delete).mockResolvedValueOnce(baseProject as any);
mockIsS3Configured = true;
vi.mocked(deleteS3FilesByEnvironmentId).mockRejectedValueOnce(new Error("fail"));
vi.mocked(deleteFilesByEnvironmentId).mockResolvedValue({
ok: false,
error: { code: StorageErrorCode.Unknown },
} as any);
vi.mocked(logger.error).mockImplementation(() => {});
await deleteProject("p1");
expect(logger.error).toHaveBeenCalled();
@@ -1,8 +1,7 @@
import "server-only";
import { isS3Configured } from "@/lib/constants";
import { createEnvironment } from "@/lib/environment/service";
import { deleteLocalFilesByEnvironmentId, deleteS3FilesByEnvironmentId } from "@/lib/storage/service";
import { validateInputs } from "@/lib/utils/validate";
import { deleteFilesByEnvironmentId } from "@/modules/storage/service";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
@@ -141,27 +140,16 @@ export const deleteProject = async (projectId: string): Promise<TProject> => {
if (project) {
// delete all files from storage related to this project
if (isS3Configured()) {
const s3FilesPromises = project.environments.map(async (environment) => {
return deleteS3FilesByEnvironmentId(environment.id);
});
const s3FilesPromises = project.environments.map(async (environment) => {
return deleteFilesByEnvironmentId(environment.id);
});
try {
await Promise.all(s3FilesPromises);
} catch (err) {
// fail silently because we don't want to throw an error if the files are not deleted
logger.error(err, "Error deleting S3 files");
}
} else {
const localFilesPromises = project.environments.map(async (environment) => {
return deleteLocalFilesByEnvironmentId(environment.id);
});
const s3FilesResult = await Promise.all(s3FilesPromises);
try {
await Promise.all(localFilesPromises);
} catch (err) {
for (const result of s3FilesResult) {
if (!result.ok) {
// fail silently because we don't want to throw an error if the files are not deleted
logger.error(err, "Error deleting local files");
logger.error(result.error, "Error deleting S3 files");
}
}
}
@@ -171,6 +159,7 @@ export const deleteProject = async (projectId: string): Promise<TProject> => {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -1,202 +1,422 @@
import { Project } from "@prisma/client";
import { cleanup, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { EditLogo } from "./edit-logo";
const baseProject: Project = {
id: "p1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Project 1",
organizationId: "org1",
styling: { allowStyleOverwrite: true },
recontactDays: 0,
inAppSurveyBranding: false,
linkSurveyBranding: false,
config: { channel: null, industry: null },
placement: "bottomRight",
clickOutsideClose: false,
darkOverlay: false,
environments: [],
languages: [],
logo: { url: "https://logo.com/logo.png", bgColor: "#fff" },
} as any;
// Mock next/image to render a plain img tag with original src
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: (props: any) => <img alt="test" {...props} />,
__esModule: true,
default: ({ src, alt, ...props }: any) => <img src={src} alt={alt} {...props} />,
}));
// Hoisted mocks
const h = vi.hoisted(() => ({
mockHandleFileUpload: vi.fn(),
mockUpdateProjectAction: vi.fn(),
mockShowStorageNotConfiguredToast: vi.fn(),
mockToastSuccess: vi.fn(),
mockToastError: vi.fn(),
}));
// Mocks
vi.mock("@/modules/storage/file-upload", () => ({
handleFileUpload: h.mockHandleFileUpload,
}));
vi.mock("@/modules/projects/settings/actions", () => ({
updateProjectAction: h.mockUpdateProjectAction,
}));
vi.mock("@/modules/ui/components/storage-not-configured-toast/lib/utils", () => ({
showStorageNotConfiguredToast: h.mockShowStorageNotConfiguredToast,
}));
vi.mock("react-hot-toast", () => ({
default: {
success: h.mockToastSuccess,
error: h.mockToastError,
},
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (k: string) => k }),
}));
vi.mock("@/modules/ui/components/file-input", () => ({
FileInput: vi.fn((props: any) => (
<div data-testid="file-input">
<button
onClick={() => props.onFileUpload(["https://example.com/uploaded-logo.png"])}
disabled={props.disabled}
data-testid="file-input-upload-button">
Upload File
</button>
</div>
)),
}));
vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({
AdvancedOptionToggle: ({ children }: any) => <div data-testid="advanced-option-toggle">{children}</div>,
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }: any) => <div data-testid="alert">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
AdvancedOptionToggle: vi.fn((props: any) => (
<div data-testid="advanced-option-toggle">
<input
type="checkbox"
checked={props.isChecked}
onChange={(e) => props.onToggle(e.target.checked)}
disabled={props.disabled}
data-testid="background-color-toggle"
/>
{props.isChecked && props.children}
</div>
)),
}));
vi.mock("@/modules/ui/components/color-picker", () => ({
ColorPicker: ({ color }: any) => <div data-testid="color-picker">{color}</div>,
ColorPicker: vi.fn((props: any) => (
<input
type="text"
value={props.color}
onChange={(e) => props.onChange(e.target.value)}
disabled={props.disabled}
data-testid="color-picker"
/>
)),
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, onDelete }: any) =>
open ? (
<div data-testid="delete-dialog">
<button data-testid="confirm-delete" onClick={onDelete}>
Delete
</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/file-input", () => ({
FileInput: () => <div data-testid="file-input" />,
}));
vi.mock("@/modules/ui/components/input", () => ({ Input: (props: any) => <input {...props} /> }));
const mockUpdateProjectAction = vi.fn(async () => ({ data: true }));
const mockGetFormattedErrorMessage = vi.fn(() => "error-message");
vi.mock("@/modules/projects/settings/actions", () => ({
updateProjectAction: () => mockUpdateProjectAction(),
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: () => mockGetFormattedErrorMessage(),
DeleteDialog: vi.fn((props: any) => (
<div data-testid="delete-dialog" style={{ display: props.open ? "block" : "none" }}>
<button onClick={props.onDelete} data-testid="confirm-delete-button">
Confirm Delete
</button>
<button onClick={() => props.setOpen(false)} data-testid="cancel-delete-button">
Cancel
</button>
</div>
)),
}));
describe("EditLogo", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders logo and edit button", () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
expect(screen.getByAltText("Logo")).toBeInTheDocument();
expect(screen.getByText("common.edit")).toBeInTheDocument();
const mockProject = {
id: "project-123",
logo: null,
} as Project;
const baseProps = {
project: mockProject,
environmentId: "env-123",
isReadOnly: false,
isStorageConfigured: true,
};
test("renders FileInput when no logo exists", () => {
render(<EditLogo {...baseProps} />);
expect(screen.getByTestId("file-input")).toBeInTheDocument();
expect(screen.getByTestId("file-input-upload-button")).toBeInTheDocument();
});
test("renders file input if no logo", () => {
render(<EditLogo project={{ ...baseProject, logo: null }} environmentId="env1" isReadOnly={false} />);
test("renders logo image when logo exists", () => {
const projectWithLogo = {
...mockProject,
logo: { url: "https://example.com/logo.png", bgColor: "#ffffff" },
} as Project;
render(<EditLogo {...baseProps} project={projectWithLogo} />);
const logoImage = screen.getByAltText("Logo");
expect(logoImage).toBeInTheDocument();
expect(logoImage.getAttribute("src")).toBe("https://example.com/logo.png");
});
test("shows read-only warning when isReadOnly is true", () => {
render(<EditLogo {...baseProps} isReadOnly={true} />);
expect(
screen.getByText("common.only_owners_managers_and_manage_access_members_can_perform_this_action")
).toBeInTheDocument();
});
test("uploads file via FileInput and enters editing mode", async () => {
render(<EditLogo {...baseProps} />);
const user = userEvent.setup();
const uploadButton = screen.getByTestId("file-input-upload-button");
await user.click(uploadButton);
// Should show the uploaded logo
await waitFor(() => {
expect(screen.getByAltText("Logo")).toBeInTheDocument();
});
// Should show editing controls
expect(screen.getByText("environments.project.look.replace_logo")).toBeInTheDocument();
expect(screen.getByText("environments.project.look.remove_logo")).toBeInTheDocument();
expect(screen.getByText("common.save")).toBeInTheDocument();
});
test("isStorageConfigured=false shows toast on file change", async () => {
render(<EditLogo {...baseProps} isStorageConfigured={false} />);
const user = userEvent.setup();
// Get the hidden file input
const fileInput = screen.getByDisplayValue("") as HTMLInputElement;
const file = new File(["dummy"], "logo.png", { type: "image/png" });
await user.upload(fileInput, file);
expect(h.mockShowStorageNotConfiguredToast).toHaveBeenCalled();
expect(h.mockHandleFileUpload).not.toHaveBeenCalled();
});
test("isStorageConfigured=false shows toast on replace logo button click", async () => {
const projectWithLogo = {
...mockProject,
logo: { url: "https://example.com/logo.png" },
} as Project;
render(<EditLogo {...baseProps} project={projectWithLogo} isStorageConfigured={false} />);
const user = userEvent.setup();
// First click edit to get into editing mode
const editButton = screen.getByText("common.edit");
await user.click(editButton);
// Click replace logo button
const replaceButton = screen.getByText("environments.project.look.replace_logo");
await user.click(replaceButton);
expect(h.mockShowStorageNotConfiguredToast).toHaveBeenCalled();
});
test("uploads file via hidden input when storage is configured", async () => {
h.mockHandleFileUpload.mockResolvedValue({ url: "https://example.com/new-logo.png" });
render(<EditLogo {...baseProps} />);
const user = userEvent.setup();
const fileInput = screen.getByDisplayValue("") as HTMLInputElement;
const file = new File(["dummy"], "logo.png", { type: "image/png" });
await user.upload(fileInput, file);
expect(h.mockHandleFileUpload).toHaveBeenCalledWith(file, "env-123");
await waitFor(() => {
expect(screen.getByAltText("Logo")).toBeInTheDocument();
});
});
test("handles file upload error", async () => {
h.mockHandleFileUpload.mockResolvedValue({ error: "Upload failed" });
render(<EditLogo {...baseProps} />);
const user = userEvent.setup();
const fileInput = screen.getByDisplayValue("") as HTMLInputElement;
const file = new File(["dummy"], "logo.png", { type: "image/png" });
await user.upload(fileInput, file);
expect(h.mockToastError).toHaveBeenCalledWith("Upload failed");
});
test("saves changes successfully", async () => {
h.mockUpdateProjectAction.mockResolvedValue({ data: true });
const projectWithLogo = {
...mockProject,
logo: { url: "https://example.com/logo.png" },
} as Project;
render(<EditLogo {...baseProps} project={projectWithLogo} />);
const user = userEvent.setup();
// Click edit to enter editing mode
const editButton = screen.getByText("common.edit");
await user.click(editButton);
// Click save
const saveButton = screen.getByText("common.save");
await user.click(saveButton);
expect(h.mockUpdateProjectAction).toHaveBeenCalledWith({
projectId: "project-123",
data: {
logo: { url: "https://example.com/logo.png", bgColor: undefined },
},
});
expect(h.mockToastSuccess).toHaveBeenCalledWith("environments.project.look.logo_updated_successfully");
});
test("handles save error", async () => {
h.mockUpdateProjectAction.mockResolvedValue({ error: "Save failed" });
const projectWithLogo = {
...mockProject,
logo: { url: "https://example.com/logo.png" },
} as Project;
render(<EditLogo {...baseProps} project={projectWithLogo} />);
const user = userEvent.setup();
// Click edit to enter editing mode
const editButton = screen.getByText("common.edit");
await user.click(editButton);
// Click save
const saveButton = screen.getByText("common.save");
await user.click(saveButton);
expect(h.mockToastError).toHaveBeenCalled();
});
test("removes logo successfully", async () => {
h.mockUpdateProjectAction.mockResolvedValue({ data: true });
const projectWithLogo = {
...mockProject,
logo: { url: "https://example.com/logo.png" },
} as Project;
render(<EditLogo {...baseProps} project={projectWithLogo} />);
const user = userEvent.setup();
// Click edit to enter editing mode
const editButton = screen.getByText("common.edit");
await user.click(editButton);
// Click remove logo
const removeButton = screen.getByText("environments.project.look.remove_logo");
await user.click(removeButton);
// Confirm deletion
const confirmButton = screen.getByTestId("confirm-delete-button");
await user.click(confirmButton);
expect(h.mockUpdateProjectAction).toHaveBeenCalledWith({
projectId: "project-123",
data: {
logo: { url: undefined, bgColor: undefined },
},
});
expect(h.mockToastSuccess).toHaveBeenCalledWith("environments.project.look.logo_removed_successfully");
});
test("toggles background color and updates color picker", async () => {
const projectWithLogo = {
...mockProject,
logo: { url: "https://example.com/logo.png" },
} as Project;
render(<EditLogo {...baseProps} project={projectWithLogo} />);
const user = userEvent.setup();
// Click edit to enter editing mode
const editButton = screen.getByText("common.edit");
await user.click(editButton);
// Toggle background color on
const bgColorToggle = screen.getByTestId("background-color-toggle");
await user.click(bgColorToggle);
// Color picker should appear
const colorPicker = screen.getByTestId("color-picker");
expect(colorPicker).toBeInTheDocument();
expect(colorPicker).toHaveValue("#f8f8f8");
// Change color
await user.click(colorPicker);
await user.keyboard("#ff0000");
// Toggle background color off
await user.click(bgColorToggle);
// Color picker should disappear
expect(screen.queryByTestId("color-picker")).not.toBeInTheDocument();
});
test("disables controls when isReadOnly is true", () => {
const projectWithLogo = {
...mockProject,
logo: { url: "https://example.com/logo.png" },
} as Project;
render(<EditLogo {...baseProps} project={projectWithLogo} isReadOnly={true} />);
const fileInput = screen.getByDisplayValue("") as HTMLInputElement;
expect(fileInput).toBeDisabled();
// Edit button should be disabled
const editButton = screen.getByText("common.edit");
expect(editButton).toBeDisabled();
});
test("edit button switches to editing mode", async () => {
const projectWithLogo = {
...mockProject,
logo: { url: "https://example.com/logo.png" },
} as Project;
render(<EditLogo {...baseProps} project={projectWithLogo} />);
const user = userEvent.setup();
// Initially shows "Edit" button
const editButton = screen.getByText("common.edit");
await user.click(editButton);
// Should show editing controls
expect(screen.getByText("environments.project.look.replace_logo")).toBeInTheDocument();
expect(screen.getByText("environments.project.look.remove_logo")).toBeInTheDocument();
expect(screen.getByText("common.save")).toBeInTheDocument();
});
test("passes isStorageConfigured to FileInput", () => {
const { rerender } = render(<EditLogo {...baseProps} isStorageConfigured={true} />);
// Check that FileInput is rendered (only when no logo exists)
expect(screen.getByTestId("file-input")).toBeInTheDocument();
// Test with isStorageConfigured=false
rerender(<EditLogo {...baseProps} isStorageConfigured={false} />);
expect(screen.getByTestId("file-input")).toBeInTheDocument();
});
test("shows alert if isReadOnly", () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={true} />);
expect(screen.getByTestId("alert")).toBeInTheDocument();
expect(screen.getByTestId("alert-description")).toHaveTextContent(
"common.only_owners_managers_and_manage_access_members_can_perform_this_action"
);
});
test("saves logo with background color when enabled", async () => {
h.mockUpdateProjectAction.mockResolvedValue({ data: true });
test("clicking edit enables editing and shows save button", async () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
const editBtn = screen.getByText("common.edit");
await userEvent.click(editBtn);
expect(screen.getByText("common.save")).toBeInTheDocument();
});
const projectWithLogo = {
...mockProject,
logo: { url: "https://example.com/logo.png", bgColor: "#ffffff" },
} as Project;
test("clicking save calls updateProjectAction and shows success toast", async () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("common.save"));
expect(mockUpdateProjectAction).toHaveBeenCalled();
});
render(<EditLogo {...baseProps} project={projectWithLogo} />);
const user = userEvent.setup();
test("shows error toast if updateProjectAction returns no data", async () => {
mockUpdateProjectAction.mockResolvedValueOnce({ data: false });
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("common.save"));
expect(mockGetFormattedErrorMessage).toHaveBeenCalled();
});
// Click edit to enter editing mode
const editButton = screen.getByText("common.edit");
await user.click(editButton);
test("shows error toast if updateProjectAction throws", async () => {
mockUpdateProjectAction.mockRejectedValueOnce(new Error("fail"));
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("common.save"));
// error toast is called
});
// Background color should already be enabled since project has bgColor
const bgColorToggle = screen.getByTestId("background-color-toggle");
expect(bgColorToggle).toBeChecked();
test("clicking remove logo opens dialog and confirms removal", async () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("environments.project.look.remove_logo"));
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
await userEvent.click(screen.getByTestId("confirm-delete"));
expect(mockUpdateProjectAction).toHaveBeenCalled();
});
// Color picker should be visible with existing color
const colorPicker = screen.getByTestId("color-picker");
expect(colorPicker).toHaveValue("#ffffff");
test("shows error toast if removeLogo returns no data", async () => {
mockUpdateProjectAction.mockResolvedValueOnce({ data: false });
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("environments.project.look.remove_logo"));
await userEvent.click(screen.getByTestId("confirm-delete"));
expect(mockGetFormattedErrorMessage).toHaveBeenCalled();
});
// Save without changing color to test the existing behavior
const saveButton = screen.getByText("common.save");
await user.click(saveButton);
test("shows error toast if removeLogo throws", async () => {
mockUpdateProjectAction.mockRejectedValueOnce(new Error("fail"));
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("environments.project.look.remove_logo"));
await userEvent.click(screen.getByTestId("confirm-delete"));
});
test("toggle background color enables/disables color picker", async () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
expect(screen.getByTestId("color-picker")).toBeInTheDocument();
});
test("saveChanges with isEditing false enables editing", async () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
// Save button should now be visible
expect(screen.getByText("common.save")).toBeInTheDocument();
});
test("saveChanges error toast on update failure", async () => {
mockUpdateProjectAction.mockRejectedValueOnce(new Error("fail"));
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("common.save"));
// error toast is called
});
test("removeLogo with isEditing false enables editing", async () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("environments.project.look.remove_logo"));
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
});
test("removeLogo error toast on update failure", async () => {
mockUpdateProjectAction.mockRejectedValueOnce(new Error("fail"));
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("environments.project.look.remove_logo"));
await userEvent.click(screen.getByTestId("confirm-delete"));
// error toast is called
});
test("toggleBackgroundColor disables and resets color", async () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
const toggle = screen.getByTestId("advanced-option-toggle");
await userEvent.click(toggle);
expect(screen.getByTestId("color-picker")).toBeInTheDocument();
});
test("DeleteDialog closes after confirming removal", async () => {
render(<EditLogo project={baseProject} environmentId="env1" isReadOnly={false} />);
await userEvent.click(screen.getByText("common.edit"));
await userEvent.click(screen.getByText("environments.project.look.remove_logo"));
await userEvent.click(screen.getByTestId("confirm-delete"));
expect(screen.queryByTestId("delete-dialog")).not.toBeInTheDocument();
expect(h.mockUpdateProjectAction).toHaveBeenCalledWith({
projectId: "project-123",
data: {
logo: { url: "https://example.com/logo.png", bgColor: "#ffffff" },
},
});
});
});
@@ -1,8 +1,8 @@
"use client";
import { handleFileUpload } from "@/app/lib/fileUpload";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateProjectAction } from "@/modules/projects/settings/actions";
import { handleFileUpload } from "@/modules/storage/file-upload";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
@@ -10,6 +10,7 @@ import { ColorPicker } from "@/modules/ui/components/color-picker";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { FileInput } from "@/modules/ui/components/file-input";
import { Input } from "@/modules/ui/components/input";
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
import { Project } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import Image from "next/image";
@@ -20,9 +21,10 @@ interface EditLogoProps {
project: Project;
environmentId: string;
isReadOnly: boolean;
isStorageConfigured: boolean;
}
export const EditLogo = ({ project, environmentId, isReadOnly }: EditLogoProps) => {
export const EditLogo = ({ project, environmentId, isReadOnly, isStorageConfigured }: EditLogoProps) => {
const { t } = useTranslate();
const [logoUrl, setLogoUrl] = useState<string | undefined>(project.logo?.url || undefined);
const [logoBgColor, setLogoBgColor] = useState<string | undefined>(project.logo?.bgColor || undefined);
@@ -49,6 +51,10 @@ export const EditLogo = ({ project, environmentId, isReadOnly }: EditLogoProps)
};
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
if (!isStorageConfigured) {
showStorageNotConfiguredToast();
return;
}
const file = event.target.files?.[0];
if (file) await handleImageUpload(file);
setIsEditing(true);
@@ -145,6 +151,7 @@ export const EditLogo = ({ project, environmentId, isReadOnly }: EditLogoProps)
setIsEditing(true);
}}
disabled={isReadOnly}
isStorageConfigured={isStorageConfigured}
/>
)}
@@ -160,7 +167,16 @@ export const EditLogo = ({ project, environmentId, isReadOnly }: EditLogoProps)
{isEditing && logoUrl && (
<>
<div className="flex gap-2">
<Button onClick={() => fileInputRef.current?.click()} variant="secondary" size="sm">
<Button
onClick={() => {
if (!isStorageConfigured) {
showStorageNotConfiguredToast();
return;
}
fileInputRef.current?.click();
}}
variant="secondary"
size="sm">
{t("environments.project.look.replace_logo")}
</Button>
<Button
@@ -37,6 +37,7 @@ interface ThemeStylingProps {
colors: string[];
isUnsplashConfigured: boolean;
isReadOnly: boolean;
isStorageConfigured: boolean;
}
export const ThemeStyling = ({
@@ -45,6 +46,7 @@ export const ThemeStyling = ({
colors,
isUnsplashConfigured,
isReadOnly,
isStorageConfigured = true,
}: ThemeStylingProps) => {
const { t } = useTranslate();
const router = useRouter();
@@ -165,6 +167,7 @@ export const ThemeStyling = ({
isSettingsPage
isUnsplashConfigured={isUnsplashConfigured}
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
isStorageConfigured={isStorageConfigured}
/>
</div>
</div>
@@ -3,6 +3,8 @@ import { getProjectByEnvironmentId } from "@/modules/projects/settings/look/lib/
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { EditLogo } from "./components/edit-logo";
import { ThemeStyling } from "./components/theme-styling";
import { ProjectLookSettingsPage } from "./page";
vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({
@@ -16,6 +18,7 @@ vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCa
vi.mock("@/lib/constants", () => ({
SURVEY_BG_COLORS: ["#fff", "#000"],
IS_FORMBRICKS_CLOUD: 1,
IS_STORAGE_CONFIGURED: true,
UNSPLASH_ACCESS_KEY: "unsplash-key",
}));
@@ -43,7 +46,7 @@ vi.mock("@/modules/projects/settings/components/project-config-navigation", () =
}));
vi.mock("./components/edit-logo", () => ({
EditLogo: () => <div data-testid="edit-logo" />,
EditLogo: vi.fn(() => <div data-testid="edit-logo" />),
}));
vi.mock("@/modules/projects/settings/look/lib/project", async () => ({
getProjectByEnvironmentId: vi.fn(),
@@ -57,6 +60,15 @@ vi.mock("@/modules/ui/components/page-header", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children, variant }: any) => (
<div data-testid="alert" data-variant={variant}>
{children}
</div>
),
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(() => {
// Return a mock translator that just returns the key
@@ -67,7 +79,7 @@ vi.mock("./components/edit-placement-form", () => ({
EditPlacementForm: () => <div data-testid="edit-placement-form" />,
}));
vi.mock("./components/theme-styling", () => ({
ThemeStyling: () => <div data-testid="theme-styling" />,
ThemeStyling: vi.fn(() => <div data-testid="theme-styling" />),
}));
describe("ProjectLookSettingsPage", () => {
@@ -118,4 +130,112 @@ describe("ProjectLookSettingsPage", () => {
const props = { params: Promise.resolve({ environmentId: "env1" }) };
await expect(ProjectLookSettingsPage(props)).rejects.toThrow("Project not found");
});
test("does not show storage warning when IS_STORAGE_CONFIGURED is true", async () => {
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({
id: "project1",
name: "Test Project",
createdAt: new Date(),
updatedAt: new Date(),
environments: [],
} as any);
const Page = await ProjectLookSettingsPage(props);
render(Page);
expect(screen.queryByTestId("alert")).not.toBeInTheDocument();
});
test("shows storage warning when IS_STORAGE_CONFIGURED is false", async () => {
// Mock IS_STORAGE_CONFIGURED as false
vi.doMock("@/lib/constants", () => ({
SURVEY_BG_COLORS: ["#fff", "#000"],
IS_FORMBRICKS_CLOUD: 1,
IS_STORAGE_CONFIGURED: false,
UNSPLASH_ACCESS_KEY: "unsplash-key",
}));
// Re-import the module to get the updated mock
const { ProjectLookSettingsPage: PageWithStorageDisabled } = await import("./page");
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({
id: "project1",
name: "Test Project",
createdAt: new Date(),
updatedAt: new Date(),
environments: [],
} as any);
const Page = await PageWithStorageDisabled(props);
render(Page);
expect(screen.getByTestId("alert")).toBeInTheDocument();
expect(screen.getByTestId("alert")).toHaveAttribute("data-variant", "warning");
expect(screen.getByTestId("alert-description")).toHaveTextContent("common.storage_not_configured");
});
test("passes isStorageConfigured=true to ThemeStyling and EditLogo components", async () => {
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({
id: "project1",
name: "Test Project",
createdAt: new Date(),
updatedAt: new Date(),
environments: [],
} as any);
const Page = await ProjectLookSettingsPage(props);
render(Page);
expect(vi.mocked(ThemeStyling)).toHaveBeenCalledWith(
expect.objectContaining({
isStorageConfigured: true,
}),
undefined
);
expect(vi.mocked(EditLogo)).toHaveBeenCalledWith(
expect.objectContaining({
isStorageConfigured: true,
}),
undefined
);
});
test("passes isStorageConfigured=false to ThemeStyling and EditLogo components when storage is not configured", async () => {
// Mock IS_STORAGE_CONFIGURED as false
vi.doMock("@/lib/constants", () => ({
SURVEY_BG_COLORS: ["#fff", "#000"],
IS_FORMBRICKS_CLOUD: 1,
IS_STORAGE_CONFIGURED: false,
UNSPLASH_ACCESS_KEY: "unsplash-key",
}));
// Re-import the module to get the updated mock
const { ProjectLookSettingsPage: PageWithStorageDisabled } = await import("./page");
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({
id: "project1",
name: "Test Project",
createdAt: new Date(),
updatedAt: new Date(),
environments: [],
} as any);
const Page = await PageWithStorageDisabled(props);
render(Page);
expect(vi.mocked(ThemeStyling)).toHaveBeenCalledWith(
expect.objectContaining({
isStorageConfigured: false,
}),
undefined
);
expect(vi.mocked(EditLogo)).toHaveBeenCalledWith(
expect.objectContaining({
isStorageConfigured: false,
}),
undefined
);
});
});
@@ -1,12 +1,13 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
import { SURVEY_BG_COLORS, UNSPLASH_ACCESS_KEY } from "@/lib/constants";
import { IS_STORAGE_CONFIGURED, SURVEY_BG_COLORS, UNSPLASH_ACCESS_KEY } from "@/lib/constants";
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import { BrandingSettingsCard } from "@/modules/ee/whitelabel/remove-branding/components/branding-settings-card";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { EditLogo } from "@/modules/projects/settings/look/components/edit-logo";
import { getProjectByEnvironmentId } from "@/modules/projects/settings/look/lib/project";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
@@ -32,6 +33,11 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
<PageHeader pageTitle={t("common.project_configuration")}>
<ProjectConfigNavigation environmentId={params.environmentId} activeId="look" />
</PageHeader>
{!IS_STORAGE_CONFIGURED && (
<Alert variant="warning">
<AlertDescription>{t("common.storage_not_configured")}</AlertDescription>
</Alert>
)}
<SettingsCard
title={t("environments.project.look.theme")}
className={cn(!isReadOnly && "max-w-7xl")}
@@ -42,12 +48,18 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
colors={SURVEY_BG_COLORS}
isUnsplashConfigured={!!UNSPLASH_ACCESS_KEY}
isReadOnly={isReadOnly}
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
</SettingsCard>
<SettingsCard
title={t("common.logo")}
description={t("environments.project.look.logo_settings_description")}>
<EditLogo project={project} environmentId={params.environmentId} isReadOnly={isReadOnly} />
<EditLogo
project={project}
environmentId={params.environmentId}
isReadOnly={isReadOnly}
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
</SettingsCard>
<SettingsCard
title={t("environments.project.look.app_survey_placement")}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Logo } from "@/modules/ui/components/logo";
import { FormbricksLogo } from "@/modules/ui/components/formbricks-logo";
import { Toaster } from "react-hot-toast";
export const SetupLayout = ({ children }: { children: React.ReactNode }) => {
@@ -10,7 +10,7 @@ export const SetupLayout = ({ children }: { children: React.ReactNode }) => {
style={{ scrollbarGutter: "stable both-edges" }}
className="flex max-h-[90vh] w-[40rem] flex-col items-center space-y-4 overflow-auto rounded-lg border bg-white p-12 text-center shadow-md">
<div className="h-20 w-20 rounded-lg bg-slate-900 p-2">
<Logo className="h-full w-full" variant="image" />
<FormbricksLogo className="h-full w-full" />
</div>
{children}
</div>
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as fileUploadModule from "./fileUpload";
import * as fileUploadModule from "./file-upload";
// Mock global fetch
const mockFetch = vi.fn();
@@ -44,13 +44,6 @@ describe("fileUpload", () => {
expect(result.url).toBe("");
});
test("should return error when file is not an image", async () => {
const file = createMockFile("test.pdf", "application/pdf", 1000);
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBe("Please upload an image file.");
expect(result.url).toBe("");
});
test("should return FILE_SIZE_EXCEEDED if arrayBuffer is > 10MB even if file.size is OK", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000); // file.size = 1KB
@@ -108,40 +101,6 @@ describe("fileUpload", () => {
expect(result.url).toBe("https://s3.example.com/file.jpg");
});
test("should handle successful file upload without presigned fields", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Mock successful API response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
signedUrl: "https://s3.example.com/upload",
fileUrl: "https://s3.example.com/file.jpg",
signingData: {
signature: "test-signature",
timestamp: 1234567890,
uuid: "test-uuid",
},
},
}),
});
// Mock successful upload response
mockFetch.mockResolvedValueOnce({
ok: true,
});
// Simulate FileReader onload
setTimeout(() => {
mockFileReader.onload();
}, 0);
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBeUndefined();
expect(result.url).toBe("https://s3.example.com/file.jpg");
});
test("should handle upload error with presigned fields", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Mock successful API response
@@ -3,6 +3,7 @@ export enum FileUploadError {
INVALID_FILE_TYPE = "Please upload an image file.",
FILE_SIZE_EXCEEDED = "File size must be less than 10 MB.",
UPLOAD_FAILED = "Upload failed. Please try again.",
INVALID_FILE_NAME = "Invalid file name. Please rename your file and try again.",
}
export const toBase64 = (file: File) =>
@@ -30,11 +31,6 @@ export const handleFileUpload = async (
url: "",
};
}
if (!file.type.startsWith("image/")) {
return { error: FileUploadError.INVALID_FILE_TYPE, url: "" };
}
const fileBuffer = await file.arrayBuffer();
const bufferBytes = fileBuffer.byteLength;
@@ -63,6 +59,16 @@ 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,
url: "",
@@ -72,58 +78,36 @@ export const handleFileUpload = async (
const json = await response.json();
const { data } = json;
const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data;
const { signedUrl, fileUrl, presignedFields } = data as {
signedUrl: string;
presignedFields: Record<string, string>;
fileUrl: string;
};
let localUploadDetails: Record<string, string> = {};
const fileBase64 = (await toBase64(file)) as string;
const formDataForS3 = new FormData();
if (signingData) {
const { signature, timestamp, uuid } = signingData;
Object.entries(presignedFields).forEach(([key, value]) => {
formDataForS3.append(key, value);
});
localUploadDetails = {
fileType: file.type,
fileName: encodeURIComponent(updatedFileName),
environmentId,
signature,
timestamp: String(timestamp),
uuid,
try {
const binaryString = atob(fileBase64.split(",")[1]);
const uint8Array = Uint8Array.from([...binaryString].map((char) => char.charCodeAt(0)));
const blob = new Blob([uint8Array], { type: file.type });
formDataForS3.append("file", blob);
} catch (err) {
console.error("Error in uploading file: ", err);
return {
error: FileUploadError.UPLOAD_FAILED,
url: "",
};
}
const fileBase64 = (await toBase64(file)) as string;
const formData: Record<string, string> = {};
const formDataForS3 = new FormData();
if (presignedFields) {
Object.entries(presignedFields as Record<string, string>).forEach(([key, value]) => {
formDataForS3.append(key, value);
});
try {
const binaryString = atob(fileBase64.split(",")[1]);
const uint8Array = Uint8Array.from([...binaryString].map((char) => char.charCodeAt(0)));
const blob = new Blob([uint8Array], { type: file.type });
formDataForS3.append("file", blob);
} catch (err) {
console.error(err);
return {
error: FileUploadError.UPLOAD_FAILED,
url: "",
};
}
}
formData.fileBase64String = fileBase64;
const uploadResponse = await fetch(signedUrl, {
method: "POST",
body: presignedFields
? formDataForS3
: JSON.stringify({
...formData,
...localUploadDetails,
}),
body: formDataForS3,
});
if (!uploadResponse.ok) {
+436
View File
@@ -0,0 +1,436 @@
import { randomUUID } from "crypto";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { StorageErrorCode } from "@formbricks/storage";
import { TAccessType } from "@formbricks/types/storage";
import {
deleteFile,
deleteFilesByEnvironmentId,
getSignedUrlForDownload,
getSignedUrlForUpload,
} from "./service";
// Mock external dependencies
vi.mock("crypto", () => ({
randomUUID: vi.fn(),
}));
vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "https://webapp.example.com",
}));
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@formbricks/storage", () => ({
StorageErrorCode: {
Unknown: "unknown",
S3ClientError: "s3_client_error",
S3CredentialsError: "s3_credentials_error",
FileNotFoundError: "file_not_found_error",
InvalidInput: "invalid_input",
},
deleteFile: vi.fn(),
deleteFilesByPrefix: vi.fn(),
getSignedDownloadUrl: vi.fn(),
getSignedUploadUrl: vi.fn(),
}));
// Import mocked dependencies
const { logger } = await import("@formbricks/logger");
const { getPublicDomain } = await import("@/lib/getPublicUrl");
const {
deleteFile: deleteFileFromS3,
deleteFilesByPrefix,
getSignedDownloadUrl,
getSignedUploadUrl,
} = await import("@formbricks/storage");
type MockedSignedUploadReturn = Awaited<ReturnType<typeof getSignedUploadUrl>>;
type MockedSignedDownloadReturn = Awaited<ReturnType<typeof getSignedDownloadUrl>>;
type MockedDeleteFileReturn = Awaited<ReturnType<typeof deleteFile>>;
type MockedDeleteFilesByPrefixReturn = Awaited<ReturnType<typeof deleteFilesByPrefix>>;
const mockUUID = "test-uuid-123-456-789-10";
describe("storage service", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(randomUUID).mockReturnValue(mockUUID);
vi.mocked(getPublicDomain).mockReturnValue("https://public.example.com");
});
describe("getSignedUrlForUpload", () => {
test("should generate signed URL for upload with unique filename", async () => {
const mockSignedUrlResponse = {
ok: true,
data: {
signedUrl: "https://s3.example.com/upload",
presignedFields: { key: "value" },
},
} as MockedSignedUploadReturn;
vi.mocked(getSignedUploadUrl).mockResolvedValue(mockSignedUrlResponse);
const result = await getSignedUrlForUpload(
"test-image.jpg",
"env-123",
"image/jpeg",
"public" as TAccessType
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
signedUrl: "https://s3.example.com/upload",
presignedFields: { key: "value" },
fileUrl: `https://public.example.com/storage/env-123/public/test-image--fid--${mockUUID}.jpg`,
});
}
expect(getSignedUploadUrl).toHaveBeenCalledWith(
`test-image--fid--${mockUUID}.jpg`,
"image/jpeg",
"env-123/public",
1024 * 1024 * 10 // 10MB default
);
});
test("should use WEBAPP_URL for private files", async () => {
const mockSignedUrlResponse = {
ok: true,
data: {
signedUrl: "https://s3.example.com/upload",
presignedFields: { key: "value" },
},
} as MockedSignedUploadReturn;
vi.mocked(getSignedUploadUrl).mockResolvedValue(mockSignedUrlResponse);
const result = await getSignedUrlForUpload(
"test-doc.pdf",
"env-123",
"application/pdf",
"private" as TAccessType
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.fileUrl).toBe(
`https://webapp.example.com/storage/env-123/private/test-doc--fid--${mockUUID}.pdf`
);
}
});
test("should properly sanitize filenames with special characters like # in URL", async () => {
const mockSignedUrlResponse = {
ok: true,
data: {
signedUrl: "https://s3.example.com/upload",
presignedFields: { key: "value" },
},
} as MockedSignedUploadReturn;
vi.mocked(getSignedUploadUrl).mockResolvedValue(mockSignedUrlResponse);
const result = await getSignedUrlForUpload(
"test#file.txt",
"env-123",
"text/plain",
"public" as TAccessType
);
expect(result.ok).toBe(true);
if (result.ok) {
// The filename should be URL-encoded to prevent # from being treated as a URL fragment
expect(result.data.fileUrl).toBe(
`https://public.example.com/storage/env-123/public/testfile--fid--${mockUUID}.txt`
);
}
expect(getSignedUploadUrl).toHaveBeenCalledWith(
`testfile--fid--${mockUUID}.txt`,
"text/plain",
"env-123/public",
1024 * 1024 * 10 // 10MB default
);
});
test("should handle files with multiple dots in filename", async () => {
const mockSignedUrlResponse = {
ok: true,
data: {
signedUrl: "https://s3.example.com/upload",
presignedFields: { key: "value" },
},
} as MockedSignedUploadReturn;
vi.mocked(getSignedUploadUrl).mockResolvedValue(mockSignedUrlResponse);
const result = await getSignedUrlForUpload(
"my.backup.file.pdf",
"env-123",
"application/pdf",
"public" as TAccessType
);
expect(result.ok).toBe(true);
expect(getSignedUploadUrl).toHaveBeenCalledWith(
`my.backup.file--fid--${mockUUID}.pdf`,
"application/pdf",
"env-123/public",
1024 * 1024 * 10
);
});
test("should use custom maxFileUploadSize when provided", async () => {
const mockSignedUrlResponse = {
ok: true,
data: {
signedUrl: "https://s3.example.com/upload",
presignedFields: { key: "value" },
},
} as MockedSignedUploadReturn;
vi.mocked(getSignedUploadUrl).mockResolvedValue(mockSignedUrlResponse);
await getSignedUrlForUpload(
"large-file.pdf",
"env-123",
"application/pdf",
"public" as TAccessType,
1024 * 1024 * 50 // 50MB
);
expect(getSignedUploadUrl).toHaveBeenCalledWith(
`large-file--fid--${mockUUID}.pdf`,
"application/pdf",
"env-123/public",
1024 * 1024 * 50
);
});
test("should return error when getSignedUploadUrl fails", async () => {
const mockErrorResponse = {
ok: false,
error: {
code: StorageErrorCode.S3ClientError,
},
} as MockedSignedUploadReturn;
vi.mocked(getSignedUploadUrl).mockResolvedValue(mockErrorResponse);
const result = await getSignedUrlForUpload(
"test-file.pdf",
"env-123",
"application/pdf",
"public" as TAccessType
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe(StorageErrorCode.S3ClientError);
}
});
test("should handle unexpected errors and return unknown error", async () => {
vi.mocked(getSignedUploadUrl).mockRejectedValue(new Error("Unexpected error"));
const result = await getSignedUrlForUpload(
"test-file.pdf",
"env-123",
"application/pdf",
"public" as TAccessType
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe(StorageErrorCode.Unknown);
}
expect(logger.error).toHaveBeenCalledWith(
{ error: expect.any(Error) },
"Error getting signed url for upload"
);
});
test("should return InvalidInput when sanitized filename is empty or invalid", async () => {
const mockErrorResponse = {
ok: false,
error: { code: StorageErrorCode.InvalidInput },
} as MockedSignedUploadReturn;
vi.mocked(getSignedUploadUrl).mockResolvedValue(mockErrorResponse);
const result = await getSignedUrlForUpload("----.png", "env-123", "image/png", "public" as TAccessType);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe(StorageErrorCode.InvalidInput);
}
});
});
describe("getSignedUrlForDownload", () => {
test("should generate signed URL for download", async () => {
const mockSignedUrlResponse = {
ok: true,
data: "https://s3.example.com/download?signature=abc123",
} as MockedSignedDownloadReturn;
vi.mocked(getSignedDownloadUrl).mockResolvedValue(mockSignedUrlResponse);
const result = await getSignedUrlForDownload("test-file.jpg", "env-123", "public" as TAccessType);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBe("https://s3.example.com/download?signature=abc123");
}
expect(getSignedDownloadUrl).toHaveBeenCalledWith("env-123/public/test-file.jpg");
});
test("should decode URI-encoded filename", async () => {
const mockSignedUrlResponse = {
ok: true,
data: "https://s3.example.com/download?signature=abc123",
} as MockedSignedDownloadReturn;
vi.mocked(getSignedDownloadUrl).mockResolvedValue(mockSignedUrlResponse);
const encodedFileName = encodeURIComponent("file with spaces.jpg");
await getSignedUrlForDownload(encodedFileName, "env-123", "private" as TAccessType);
expect(getSignedDownloadUrl).toHaveBeenCalledWith("env-123/private/file with spaces.jpg");
});
test("should return error when getSignedDownloadUrl fails", async () => {
const mockErrorResponse = {
ok: false,
error: {
code: StorageErrorCode.S3ClientError,
},
} as MockedSignedDownloadReturn;
vi.mocked(getSignedDownloadUrl).mockResolvedValue(mockErrorResponse);
const result = await getSignedUrlForDownload("missing-file.jpg", "env-123", "public" as TAccessType);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe(StorageErrorCode.S3ClientError);
}
});
test("should handle unexpected errors and return unknown error", async () => {
vi.mocked(getSignedDownloadUrl).mockRejectedValue(new Error("Network error"));
const result = await getSignedUrlForDownload("test-file.jpg", "env-123", "public" as TAccessType);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe(StorageErrorCode.Unknown);
}
expect(logger.error).toHaveBeenCalledWith(
{ error: expect.any(Error) },
"Error getting signed url for download"
);
});
test("should handle files with special characters", async () => {
const mockSignedUrlResponse = {
ok: true,
data: "https://s3.example.com/download?signature=abc123",
} as MockedSignedDownloadReturn;
vi.mocked(getSignedDownloadUrl).mockResolvedValue(mockSignedUrlResponse);
const specialFileName = "file%20with%20%26%20symbols.jpg";
await getSignedUrlForDownload(specialFileName, "env-123", "public" as TAccessType);
expect(getSignedDownloadUrl).toHaveBeenCalledWith("env-123/public/file with & symbols.jpg");
});
});
describe("deleteFile", () => {
test("should call deleteFileFromS3 with correct file key", async () => {
const mockSuccessResult = {
ok: true,
data: undefined,
} as MockedDeleteFileReturn;
vi.mocked(deleteFileFromS3).mockResolvedValue(mockSuccessResult);
const result = await deleteFile("env-123", "public" as TAccessType, "test-file.jpg");
expect(result).toEqual(mockSuccessResult);
expect(deleteFileFromS3).toHaveBeenCalledWith("env-123/public/test-file.jpg");
});
test("should handle private access type", async () => {
const mockSuccessResult = {
ok: true,
data: undefined,
} as MockedDeleteFileReturn;
vi.mocked(deleteFileFromS3).mockResolvedValue(mockSuccessResult);
const result = await deleteFile("env-456", "private" as TAccessType, "private-doc.pdf");
expect(result).toEqual(mockSuccessResult);
expect(deleteFileFromS3).toHaveBeenCalledWith("env-456/private/private-doc.pdf");
});
test("should handle when deleteFileFromS3 returns error", async () => {
const mockErrorResult = {
ok: false,
error: {
code: StorageErrorCode.Unknown,
},
} as MockedDeleteFileReturn;
vi.mocked(deleteFileFromS3).mockResolvedValue(mockErrorResult);
const result = await deleteFile("env-123", "public" as TAccessType, "test-file.jpg");
expect(result).toEqual(mockErrorResult);
expect(deleteFileFromS3).toHaveBeenCalledWith("env-123/public/test-file.jpg");
});
});
describe("deleteFilesByEnvironmentId", () => {
test("should call deleteFilesByPrefix with environment ID", async () => {
const mockSuccessResult = {
ok: true,
data: undefined,
} as MockedDeleteFilesByPrefixReturn;
vi.mocked(deleteFilesByPrefix).mockResolvedValue(mockSuccessResult);
const result = await deleteFilesByEnvironmentId("env-123");
expect(result).toEqual(mockSuccessResult);
expect(deleteFilesByPrefix).toHaveBeenCalledWith("env-123");
});
test("should handle when deleteFilesByPrefix returns error", async () => {
const mockErrorResult = {
ok: false,
error: {
code: StorageErrorCode.Unknown,
},
} as MockedDeleteFilesByPrefixReturn;
vi.mocked(deleteFilesByPrefix).mockResolvedValue(mockErrorResult);
const result = await deleteFilesByEnvironmentId("env-123");
expect(result).toEqual(mockErrorResult);
expect(deleteFilesByPrefix).toHaveBeenCalledWith("env-123");
});
});
});
+104
View File
@@ -0,0 +1,104 @@
import { WEBAPP_URL } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { randomUUID } from "crypto";
import { logger } from "@formbricks/logger";
import {
type StorageError,
StorageErrorCode,
deleteFile as deleteFileFromS3,
deleteFilesByPrefix,
getSignedDownloadUrl,
getSignedUploadUrl,
} from "@formbricks/storage";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { TAccessType } from "@formbricks/types/storage";
import { sanitizeFileName } from "./utils";
export const getSignedUrlForUpload = async (
fileName: string,
environmentId: string,
fileType: string,
accessType: TAccessType,
maxFileUploadSize: number = 1024 * 1024 * 10 // 10MB
): Promise<
Result<
{
signedUrl: string;
presignedFields: Record<string, string>;
fileUrl: string;
},
StorageError
>
> => {
try {
const safeFileName = sanitizeFileName(fileName);
if (!safeFileName) {
return err({ code: StorageErrorCode.InvalidInput });
}
const fileNameWithoutExtension = safeFileName.split(".").slice(0, -1).join(".");
const fileExtension = safeFileName.split(".").pop();
const updatedFileName = `${fileNameWithoutExtension}--fid--${randomUUID()}.${fileExtension}`;
const signedUrlResult = await getSignedUploadUrl(
updatedFileName,
fileType,
`${environmentId}/${accessType}`,
maxFileUploadSize
);
if (!signedUrlResult.ok) {
return signedUrlResult;
}
// Use PUBLIC_URL for public files, WEBAPP_URL for private files
const baseUrl = accessType === "public" ? getPublicDomain() : WEBAPP_URL;
return ok({
signedUrl: signedUrlResult.data.signedUrl,
presignedFields: signedUrlResult.data.presignedFields,
fileUrl: new URL(
`${baseUrl}/storage/${environmentId}/${accessType}/${encodeURIComponent(updatedFileName)}`
).href,
});
} catch (error) {
logger.error({ error }, "Error getting signed url for upload");
return err({
code: StorageErrorCode.Unknown,
});
}
};
export const getSignedUrlForDownload = async (
fileName: string,
environmentId: string,
accessType: TAccessType
): Promise<Result<string, StorageError>> => {
try {
const fileNameDecoded = decodeURIComponent(fileName);
const fileKey = `${environmentId}/${accessType}/${fileNameDecoded}`;
const signedUrlResult = await getSignedDownloadUrl(fileKey);
if (!signedUrlResult.ok) {
return signedUrlResult;
}
return signedUrlResult;
} catch (error) {
logger.error({ error }, "Error getting signed url for download");
return err({
code: StorageErrorCode.Unknown,
});
}
};
// We don't need to return or throw any errors, even if the file doesn't exist, we should not fail the request, nor log any errors, those will be handled by the deleteFile function
export const deleteFile = async (environmentId: string, accessType: TAccessType, fileName: string) =>
await deleteFileFromS3(`${environmentId}/${accessType}/${fileName}`);
// We don't need to return or throw any errors, even if the files don't exist, we should not fail the request, nor log any errors, those will be handled by the deleteFilesByPrefix function
export const deleteFilesByEnvironmentId = async (environmentId: string) =>
await deleteFilesByPrefix(environmentId);
@@ -1,27 +1,160 @@
import * as storageUtils from "@/lib/storage/utils";
import { describe, expect, test, vi } from "vitest";
import { ZAllowedFileExtension } from "@formbricks/types/common";
import { TResponseData } from "@formbricks/types/responses";
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
import {
isAllowedFileExtension,
isValidFileTypeForExtension,
isValidImageFile,
validateFile,
sanitizeFileName,
validateFileUploads,
validateSingleFile,
} from "./fileValidation";
} from "@/modules/storage/utils";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { StorageErrorCode } from "@formbricks/storage";
import { TResponseData } from "@formbricks/types/responses";
import { ZAllowedFileExtension } from "@formbricks/types/storage";
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
// Mock getOriginalFileNameFromUrl function
vi.mock("@/lib/storage/utils", () => ({
getOriginalFileNameFromUrl: vi.fn((url) => {
// Extract filename from the URL for testing purposes
const parts = url.split("/");
return parts[parts.length - 1];
}),
}));
// Mock the getOriginalFileNameFromUrl function
const mockGetOriginalFileNameFromUrl = vi.hoisted(() => vi.fn());
vi.mock("@/modules/storage/utils", async () => {
const actual = await vi.importActual("@/modules/storage/utils");
return {
...actual,
getOriginalFileNameFromUrl: mockGetOriginalFileNameFromUrl,
};
});
describe("storage utils", () => {
beforeEach(() => {
vi.resetAllMocks();
// Default: derive filename from URL path for positive-path tests
mockGetOriginalFileNameFromUrl.mockImplementation((url: string) => {
try {
return new URL(url).pathname.split("/").filter(Boolean).pop();
} catch {
return undefined;
}
});
});
describe("sanitizeFileName", () => {
test("returns empty string for empty input", () => {
expect(sanitizeFileName("")).toBe("");
});
test("keeps a normal filename unchanged", () => {
expect(sanitizeFileName("photo.jpg")).toBe("photo.jpg");
});
test("replaces slashes and backslashes with dashes", () => {
expect(sanitizeFileName("a/b\\c.txt")).toBe("a-b-c.txt");
});
test("removes reserved and control characters including #", () => {
expect(sanitizeFileName("we<>:\"|?*`'#ird.pdf")).toBe("weird.pdf");
expect(sanitizeFileName("test#file.png")).toBe("testfile.png");
});
test("collapses whitespace and trims", () => {
expect(sanitizeFileName(" my file name .jpg ")).toBe("my file name.jpg");
});
test("keeps only last extension when multiple dots present", () => {
expect(sanitizeFileName("my.backup.file.pdf")).toBe("my.backup.file.pdf");
});
test("returns empty string for base of only hyphens or dots", () => {
expect(sanitizeFileName("----.png")).toBe("");
expect(sanitizeFileName("....png")).toBe("");
});
test("sanitizes extension to alphanumeric only", () => {
expect(sanitizeFileName("file.pn#g")).toBe("file.png");
});
test("truncates overly long base name", () => {
const longBase = "a".repeat(300);
const result = sanitizeFileName(`${longBase}.txt`);
// base should be cut to 200 chars
expect(result).toBe(`${"a".repeat(200)}.txt`);
});
});
describe("getErrorResponseFromStorageError", () => {
test("returns appropriate responses for each storage error code", async () => {
// Spy on real module; keep behavior isolated to this test
const responseMod = await import("@/app/lib/api/response");
const spyNotFound = vi
.spyOn(responseMod.responses, "notFoundResponse")
.mockImplementation(
(_entity: string, _id?: string | null, _public?: boolean) => new Response(null, { status: 404 })
);
const spyBadReq = vi
.spyOn(responseMod.responses, "badRequestResponse")
.mockImplementation(
(_msg: string, _details?: unknown, _public?: boolean) => new Response(null, { status: 400 })
);
const spyISE = vi
.spyOn(responseMod.responses, "internalServerErrorResponse")
.mockImplementation((_msg: string, _public?: boolean) => new Response(null, { status: 500 }));
const { getErrorResponseFromStorageError } = await import("@/modules/storage/utils");
// FileNotFoundError uses notFoundResponse with details.fileName or null
const r404 = getErrorResponseFromStorageError(
{ code: StorageErrorCode.FileNotFoundError },
{
fileName: "file.png",
}
);
expect(r404.status).toBe(404);
// InvalidInput -> 400
const r400 = getErrorResponseFromStorageError({ code: StorageErrorCode.InvalidInput }, {
reason: "bad",
} as any);
expect(r400.status).toBe(400);
// S3 related and Unknown -> 500
const r500a = getErrorResponseFromStorageError({ code: StorageErrorCode.S3ClientError });
expect(r500a.status).toBe(500);
const r500b = getErrorResponseFromStorageError({ code: StorageErrorCode.S3CredentialsError });
expect(r500b.status).toBe(500);
const r500c = getErrorResponseFromStorageError({ code: StorageErrorCode.Unknown });
expect(r500c.status).toBe(500);
// Default branch (unknown string) -> 500
const r500d = getErrorResponseFromStorageError({ code: "something_else" as any });
expect(r500d.status).toBe(500);
spyNotFound.mockRestore();
spyBadReq.mockRestore();
spyISE.mockRestore();
});
});
describe("getOriginalFileNameFromUrl (actual)", () => {
test("extracts original name from full URL with fid and extension", async () => {
const { getOriginalFileNameFromUrl } =
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
const url = "https://cdn.example.com/storage/env/public/photo--fid--12345.png?x=1#hash";
expect(getOriginalFileNameFromUrl(url)).toBe("photo.png");
});
test("handles /storage/ relative path and missing fid", async () => {
const { getOriginalFileNameFromUrl } =
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
const path = "/storage/env/public/Document%20Name.pdf";
expect(getOriginalFileNameFromUrl(path)).toBe("/storage/env/public/Document Name.pdf");
});
test("returns empty string on invalid URL input", async () => {
const { getOriginalFileNameFromUrl } =
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
expect(getOriginalFileNameFromUrl("ht!tp://%$^&")).toBe("");
});
});
describe("fileValidation", () => {
describe("isAllowedFileExtension", () => {
test("should return false for a file with no extension", () => {
expect(isAllowedFileExtension("filename")).toBe(false);
@@ -74,70 +207,24 @@ describe("fileValidation", () => {
});
});
describe("validateFile", () => {
test("should return valid: false when file extension is not allowed", () => {
const result = validateFile("script.php", "application/php");
expect(result.valid).toBe(false);
expect(result.error).toContain("File type not allowed");
});
test("should return valid: false when file type does not match extension", () => {
const result = validateFile("image.png", "application/pdf");
expect(result.valid).toBe(false);
expect(result.error).toContain("File type doesn't match");
});
test("should return valid: true when file is allowed and type matches extension", () => {
const result = validateFile("image.jpg", "image/jpeg");
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
test("should return valid: true for allowed file types", () => {
Object.values(ZAllowedFileExtension.enum).forEach((ext) => {
// Skip testing extensions that don't have defined MIME types in the test
if (["jpg", "png", "pdf"].includes(ext)) {
const mimeType = ext === "jpg" ? "image/jpeg" : ext === "png" ? "image/png" : "application/pdf";
const result = validateFile(`file.${ext}`, mimeType);
expect(result.valid).toBe(true);
}
});
});
test("should return valid: false for files with no extension", () => {
const result = validateFile("noextension", "application/octet-stream");
expect(result.valid).toBe(false);
});
test("should handle attempts to bypass with double extension", () => {
const result = validateFile("malicious.jpg.php", "image/jpeg");
expect(result.valid).toBe(false);
});
});
describe("validateSingleFile", () => {
test("should return true for allowed file extension", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("image.jpg");
mockGetOriginalFileNameFromUrl.mockReturnValueOnce("image.jpg");
expect(validateSingleFile("https://example.com/image.jpg", ["jpg", "png"])).toBe(true);
});
test("should return false for disallowed file extension", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("malicious.exe");
mockGetOriginalFileNameFromUrl.mockReturnValueOnce("malicious.exe");
expect(validateSingleFile("https://example.com/malicious.exe", ["jpg", "png"])).toBe(false);
});
test("should return true when no allowed extensions are specified", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("image.jpg");
mockGetOriginalFileNameFromUrl.mockReturnValueOnce("image.jpg");
expect(validateSingleFile("https://example.com/image.jpg")).toBe(true);
});
test("should return false when file name cannot be extracted", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce(undefined);
expect(validateSingleFile("https://example.com/unknown")).toBe(false);
});
test("should return false when file has no extension", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("filewithoutextension");
mockGetOriginalFileNameFromUrl.mockReturnValueOnce("filewithoutextension");
expect(validateSingleFile("https://example.com/filewithoutextension", ["jpg"])).toBe(false);
});
});
@@ -209,7 +296,7 @@ describe("fileValidation", () => {
test("should return false when file name cannot be extracted", () => {
// Mock implementation to return null for this specific URL
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => undefined);
mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => undefined);
const responseData = {
question1: ["https://example.com/invalid-url"],
@@ -227,9 +314,7 @@ describe("fileValidation", () => {
});
test("should return false when file has no extension", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(
() => "file-without-extension"
);
mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => "file-without-extension");
const responseData = {
question1: ["https://example.com/storage/file-without-extension"],
@@ -292,24 +377,22 @@ describe("fileValidation", () => {
});
test("should return false when file name cannot be extracted", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => undefined);
mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => undefined);
expect(isValidImageFile("https://example.com/invalid-url")).toBe(false);
});
test("should return false when file has no extension", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(
() => "image-without-extension"
);
mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => "image-without-extension");
expect(isValidImageFile("https://example.com/image-without-extension")).toBe(false);
});
test("should return false when file name ends with a dot", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => "image.");
mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => "image.");
expect(isValidImageFile("https://example.com/image.")).toBe(false);
});
test("should handle case insensitivity correctly", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => "image.JPG");
mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => "image.JPG");
expect(isValidImageFile("https://example.com/image.JPG")).toBe(true);
});
});
+165
View File
@@ -0,0 +1,165 @@
import { responses } from "@/app/lib/api/response";
import { logger } from "@formbricks/logger";
import { StorageError, StorageErrorCode } from "@formbricks/storage";
import { TResponseData } from "@formbricks/types/responses";
import { TAllowedFileExtension, ZAllowedFileExtension, mimeTypes } from "@formbricks/types/storage";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
export const getOriginalFileNameFromUrl = (fileURL: string) => {
try {
const lastSegment = fileURL.startsWith("/storage/")
? fileURL
: (new URL(fileURL).pathname.split("/").pop() ?? "");
const fileNameFromURL = lastSegment.split(/[?#]/)[0];
const [namePart, fidPart] = fileNameFromURL.split("--fid--");
if (!fidPart) return namePart ? decodeURIComponent(namePart) : "";
const dotIdx = fileNameFromURL.lastIndexOf(".");
const hasExt = dotIdx > fileNameFromURL.indexOf("--fid--");
const ext = hasExt ? fileNameFromURL.slice(dotIdx + 1) : "";
return decodeURIComponent(ext ? `${namePart}.${ext}` : namePart);
} catch (error) {
logger.error({ error, fileURL }, "Error parsing file URL");
return "";
}
};
/**
* Sanitize a provided file name to a safe subset.
* - Removes path separators and backslashes to avoid implicit prefixes
* - Drops ASCII control chars and reserved URL chars which often break S3 form fields
* - Collapses whitespace
* - Limits length to a reasonable maximum
* - Preserves last extension only
*/
export const sanitizeFileName = (rawFileName: string): string => {
if (!rawFileName) return "";
// Normalize to NFC to avoid weird Unicode composition differences
let name = rawFileName.normalize("NFC");
// Replace path separators/backslashes with dash
name = name.replace(/[\\/]/g, "-");
// Disallow: # <> : " | ? * ` ' and control whitespace
name = name.replace(/[#<>:"|?*`']/g, "");
// Collapse and trim whitespace
name = name.replace(/\s+/g, " ").trim();
// Split into base and extension; keep only the last extension
const parts = name.split(".");
const hasExt = parts.length > 1;
const ext = hasExt ? parts.pop()! : "";
let base = (hasExt ? parts.join(".") : parts[0]).trim();
// Fallback base if empty after sanitization
if (!base) return "";
// Reject bases that are only punctuation like hyphens or dots
if (/^-+$/.test(base) || /^\.+$/.test(base)) return "";
// Enforce max lengths (S3 key limit is 1024; be conservative for filename)
const MAX_BASE = 200;
const MAX_EXT = 20;
if (base.length > MAX_BASE) base = base.slice(0, MAX_BASE);
const safeExt = ext.slice(0, MAX_EXT).replace(/[^A-Za-z0-9]/g, "");
const result = safeExt ? `${base}.${safeExt}` : base;
// Final guard: empty or just dots/hyphens shouldn't pass
if (!result || /^\.*$/.test(result) || /^-+$/.test(result)) return "";
return result;
};
/**
* Validates if the file extension is allowed
* @param fileName The name of the file to validate
* @returns {boolean} True if the file extension is allowed, false otherwise
*/
export const isAllowedFileExtension = (fileName: string): boolean => {
// Extract the file extension
const extension = fileName.split(".").pop()?.toLowerCase();
if (!extension || extension === fileName.toLowerCase()) return false;
// Check if the extension is in the allowed list
return Object.values(ZAllowedFileExtension.enum).includes(extension as TAllowedFileExtension);
};
/**
* Validates if the file type matches the extension
* @param fileName The name of the file
* @param mimeType The MIME type of the file
* @returns {boolean} True if the file type matches the extension, false otherwise
*/
export const isValidFileTypeForExtension = (fileName: string, mimeType: string): boolean => {
const extension = fileName.split(".").pop()?.toLowerCase();
if (!extension || extension === fileName.toLowerCase()) return false;
// Basic MIME type validation for common file types
const mimeTypeLower = mimeType.toLowerCase();
// Check if the MIME type matches the expected type for this extension
return mimeTypes[extension] === mimeTypeLower;
};
export const validateSingleFile = (
fileUrl: string,
allowedFileExtensions?: TAllowedFileExtension[]
): boolean => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
if (!fileName) return false;
const extension = fileName.split(".").pop()?.toLowerCase();
if (!extension) return false;
return !allowedFileExtensions || allowedFileExtensions.includes(extension as TAllowedFileExtension);
};
export const validateFileUploads = (data?: TResponseData, questions?: TSurveyQuestion[]): boolean => {
if (!data) return true;
for (const key of Object.keys(data)) {
const question = questions?.find((q) => q.id === key);
if (!question || question.type !== TSurveyQuestionTypeEnum.FileUpload) continue;
const fileUrls = data[key];
if (!Array.isArray(fileUrls) || !fileUrls.every((url) => typeof url === "string")) return false;
for (const fileUrl of fileUrls) {
if (!validateSingleFile(fileUrl, question.allowedFileExtensions)) return false;
}
}
return true;
};
export const isValidImageFile = (fileUrl: string): boolean => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
if (!fileName || fileName.endsWith(".")) return false;
const extension = fileName.split(".").pop()?.toLowerCase();
if (!extension) return false;
const imageExtensions = ["png", "jpeg", "jpg", "webp", "heic"];
return imageExtensions.includes(extension);
};
export const getErrorResponseFromStorageError = (
error: StorageError,
details?: Record<string, string>
): Response => {
switch (error.code) {
case StorageErrorCode.FileNotFoundError:
return responses.notFoundResponse("file", details?.fileName ?? null, true);
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);
case StorageErrorCode.Unknown:
return responses.internalServerErrorResponse("Internal server error", true);
default: {
return responses.internalServerErrorResponse("Internal server error", true);
}
}
};
@@ -1,4 +1,6 @@
import { createI18nString } from "@/lib/i18n/utils";
// Import FileInput to get the mocked version
import { FileInput } from "@/modules/ui/components/file-input";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -187,7 +189,7 @@ vi.mock("@/modules/survey/components/question-form-input/components/recall-wrapp
// Mock file input component
vi.mock("@/modules/ui/components/file-input", () => ({
FileInput: () => <div data-testid="file-input">environments.surveys.edit.add_photo_or_video</div>,
FileInput: vi.fn(() => <div data-testid="file-input">environments.surveys.edit.add_photo_or_video</div>),
}));
// Mock license-check module
@@ -335,6 +337,7 @@ describe("QuestionFormInput", () => {
setSelectedLanguageCode={mockSetSelectedLanguageCode}
label="Headline"
locale="en-US"
isStorageConfigured={true}
/>
);
@@ -357,6 +360,7 @@ describe("QuestionFormInput", () => {
setSelectedLanguageCode={mockSetSelectedLanguageCode}
label="Headline"
locale="en-US"
isStorageConfigured={true}
/>
);
@@ -390,6 +394,7 @@ describe("QuestionFormInput", () => {
setSelectedLanguageCode={mockSetSelectedLanguageCode}
label="Choice"
locale="en-US"
isStorageConfigured={true}
/>
);
@@ -421,6 +426,7 @@ describe("QuestionFormInput", () => {
setSelectedLanguageCode={mockSetSelectedLanguageCode}
label="Welcome Headline"
locale="en-US"
isStorageConfigured={true}
/>
);
@@ -448,6 +454,7 @@ describe("QuestionFormInput", () => {
setSelectedLanguageCode={mockSetSelectedLanguageCode}
label="End Screen Headline"
locale="en-US"
isStorageConfigured={true}
/>
);
@@ -477,6 +484,7 @@ describe("QuestionFormInput", () => {
setSelectedLanguageCode={mockSetSelectedLanguageCode}
label="Lower Label"
locale="en-US"
isStorageConfigured={true}
/>
);
@@ -502,11 +510,12 @@ describe("QuestionFormInput", () => {
setSelectedLanguageCode={mockSetSelectedLanguageCode}
label="Headline"
locale="en-US"
isStorageConfigured={true}
/>
);
// The button should have aria-label="Toggle image uploader"
const toggleButton = screen.getByTestId("Toggle image uploader");
// The button should have data-testid="toggle-image-uploader-button"
const toggleButton = screen.getByTestId("toggle-image-uploader-button");
await user.click(toggleButton);
expect(screen.getByTestId("file-input")).toBeInTheDocument();
@@ -527,6 +536,7 @@ describe("QuestionFormInput", () => {
setSelectedLanguageCode={mockSetSelectedLanguageCode}
label="Subheader"
locale="en-US"
isStorageConfigured={true}
/>
);
@@ -551,6 +561,7 @@ describe("QuestionFormInput", () => {
setSelectedLanguageCode={mockSetSelectedLanguageCode}
label="Headline"
locale="en-US"
isStorageConfigured={true}
/>
);
@@ -571,6 +582,7 @@ describe("QuestionFormInput", () => {
label="Headline"
maxLength={10}
locale="en-US"
isStorageConfigured={true}
/>
);
@@ -592,6 +604,7 @@ describe("QuestionFormInput", () => {
label="Headline"
placeholder="Custom placeholder"
locale="en-US"
isStorageConfigured={true}
/>
);
@@ -616,6 +629,7 @@ describe("QuestionFormInput", () => {
label="Headline"
onBlur={onBlurMock}
locale="en-US"
isStorageConfigured={true}
/>
);
@@ -625,4 +639,179 @@ describe("QuestionFormInput", () => {
expect(onBlurMock).toHaveBeenCalled();
});
describe("isStorageConfigured functionality", () => {
test("passes isStorageConfigured=true to FileInput when image uploader is shown for headline", async () => {
const user = userEvent.setup();
render(
<QuestionFormInput
id="headline"
value={createI18nString("Test Headline", ["en"])}
localSurvey={mockSurvey}
questionIdx={0}
updateQuestion={mockUpdateQuestion}
isInvalid={false}
selectedLanguageCode="en"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
label="Headline"
locale="en-US"
isStorageConfigured={true}
/>
);
// Click the image uploader toggle button
const toggleButton = screen.getByTestId("toggle-image-uploader-button");
await user.click(toggleButton);
// Verify FileInput receives isStorageConfigured=true
expect(vi.mocked(FileInput)).toHaveBeenCalledWith(
expect.objectContaining({
isStorageConfigured: true,
}),
undefined
);
});
test("passes isStorageConfigured=false to FileInput when image uploader is shown for headline", async () => {
const user = userEvent.setup();
render(
<QuestionFormInput
id="headline"
value={createI18nString("Test Headline", ["en"])}
localSurvey={mockSurvey}
questionIdx={0}
updateQuestion={mockUpdateQuestion}
isInvalid={false}
selectedLanguageCode="en"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
label="Headline"
locale="en-US"
isStorageConfigured={false}
/>
);
// Click the image uploader toggle button
const toggleButton = screen.getByTestId("toggle-image-uploader-button");
await user.click(toggleButton);
// Verify FileInput receives isStorageConfigured=false
expect(vi.mocked(FileInput)).toHaveBeenCalledWith(
expect.objectContaining({
isStorageConfigured: false,
}),
undefined
);
});
test("does not render FileInput when id is not headline", async () => {
render(
<QuestionFormInput
id="subheader"
value={createI18nString("Test Subheader", ["en"])}
localSurvey={mockSurvey}
questionIdx={0}
updateQuestion={mockUpdateQuestion}
isInvalid={false}
selectedLanguageCode="en"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
label="Subheader"
locale="en-US"
isStorageConfigured={false}
/>
);
// Image uploader toggle button should not be present for non-headline fields
expect(screen.queryByTestId("toggle-image-uploader-button")).not.toBeInTheDocument();
// FileInput should not be called
expect(vi.mocked(FileInput)).not.toHaveBeenCalled();
});
test("FileInput is only rendered when image uploader is toggled on for headline", async () => {
const user = userEvent.setup();
render(
<QuestionFormInput
id="headline"
value={createI18nString("Test Headline", ["en"])}
localSurvey={mockSurvey}
questionIdx={0}
updateQuestion={mockUpdateQuestion}
isInvalid={false}
selectedLanguageCode="en"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
label="Headline"
locale="en-US"
isStorageConfigured={true}
/>
);
// Initially, FileInput should not be rendered (uploader is closed by default)
expect(vi.mocked(FileInput)).not.toHaveBeenCalled();
// Click the image uploader toggle button
const toggleButton = screen.getByTestId("toggle-image-uploader-button");
await user.click(toggleButton);
// Now FileInput should be rendered with correct props
expect(vi.mocked(FileInput)).toHaveBeenCalledWith(
expect.objectContaining({
id: "question-image",
allowedFileExtensions: ["png", "jpeg", "jpg", "webp", "heic"],
environmentId: mockSurvey.environmentId,
isVideoAllowed: true,
isStorageConfigured: true,
}),
undefined
);
});
test("FileInput receives correct file upload callback", async () => {
const user = userEvent.setup();
render(
<QuestionFormInput
id="headline"
value={createI18nString("Test Headline", ["en"])}
localSurvey={mockSurvey}
questionIdx={0}
updateQuestion={mockUpdateQuestion}
isInvalid={false}
selectedLanguageCode="en"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
label="Headline"
locale="en-US"
isStorageConfigured={true}
/>
);
// Click the image uploader toggle button
const toggleButton = screen.getByTestId("toggle-image-uploader-button");
await user.click(toggleButton);
// Get the onFileUpload callback that was passed to FileInput
const fileInputCall = vi.mocked(FileInput).mock.calls[0][0];
const onFileUpload = fileInputCall.onFileUpload;
// Simulate an image upload
onFileUpload(["https://example.com/image.jpg"], "image");
// Verify updateQuestion was called with the correct image data
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
imageUrl: "https://example.com/image.jpg",
videoUrl: "",
});
// Simulate a video upload
onFileUpload(["https://example.com/video.mp4"], "video");
// Verify updateQuestion was called with the correct video data
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
videoUrl: "https://example.com/video.mp4",
imageUrl: "",
});
});
});
});
@@ -55,6 +55,7 @@ interface QuestionFormInputProps {
className?: string;
locale: TUserLocale;
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
isStorageConfigured: boolean;
}
export const QuestionFormInput = ({
@@ -76,6 +77,7 @@ export const QuestionFormInput = ({
className,
locale,
onKeyDown,
isStorageConfigured = true,
}: QuestionFormInputProps) => {
const { t } = useTranslate();
const defaultLanguageCode =
@@ -345,6 +347,7 @@ export const QuestionFormInput = ({
fileUrl={getFileUrl()}
videoUrl={getVideoUrl()}
isVideoAllowed={true}
isStorageConfigured={isStorageConfigured}
/>
)}
@@ -407,6 +410,7 @@ export const QuestionFormInput = ({
variant="secondary"
size="icon"
aria-label="Toggle image uploader"
data-testid="toggle-image-uploader-button"
className="ml-2"
onClick={(e) => {
e.preventDefault();
@@ -6,7 +6,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TUser } from "@formbricks/types/user";
import { updateUser } from "./user";
vi.mock("@/lib/fileValidation", () => ({
vi.mock("@/modules/storage/utils", () => ({
isValidImageFile: vi.fn(),
}));

Some files were not shown because too many files have changed in this diff Show More