Compare commits

...

53 Commits

Author SHA1 Message Date
pandeymangg
86804db467 e2e 2025-09-10 13:01:16 +05:30
pandeymangg
13d6af0183 e2e 2025-09-10 12:28:51 +05:30
pandeymangg
af49205488 e2e 2025-09-10 11:46:49 +05:30
pandeymangg
ddc3c97297 e2e 2025-09-10 11:22:12 +05:30
pandeymangg
51b116bd6b e2e 2025-09-10 10:38:07 +05:30
pandeymangg
145579d864 e2e 2025-09-09 23:23:05 +05:30
pandeymangg
963d1e229f e2e 2025-09-09 23:18:00 +05:30
pandeymangg
d4e1062b53 e2e 2025-09-09 22:39:03 +05:30
pandeymangg
8abb96daa4 feedback 2025-09-09 17:54:08 +05:30
pandeymangg
8b30f59fb9 feedback 2025-09-09 17:49:37 +05:30
pandeymangg
6084669d2d feedback 2025-09-09 17:27:00 +05:30
pandeymangg
fd6aec4dc0 fix: build 2025-09-09 17:15:51 +05:30
pandeymangg
f4b9400ffc fix csp 2025-09-09 17:06:47 +05:30
pandeymangg
738f9e4b8e feedback 2025-09-09 16:23:01 +05:30
pandeymangg
81ff63797c csp 2025-09-09 16:20:48 +05:30
pandeymangg
45b19772f2 fix 2025-09-09 16:05:04 +05:30
pandeymangg
07a150443f coderabbit feedback 2025-09-09 16:04:56 +05:30
pandeymangg
b4d1916812 coderabbit feedback 2025-09-09 14:28:54 +05:30
pandeymangg
11eb8d622f feedback 2025-09-09 12:32:16 +05:30
pandeymangg
2a202b3194 fix test 2025-09-08 11:54:29 +05:30
pandeymangg
8fafc4f940 fixes 2025-09-08 11:14:14 +05:30
pandeymangg
fb38a606f9 file name sanitization 2025-09-05 13:23:11 +05:30
pandeymangg
54065d6e13 adds tests 2025-09-03 12:49:29 +05:30
pandeymangg
0090c31ff8 adds tests 2025-09-03 12:28:33 +05:30
pandeymangg
908a78e211 adds tests 2025-09-03 12:11:39 +05:30
pandeymangg
d55a17086c coderabbit feedback 2025-09-03 11:39:22 +05:30
pandeymangg
2a938caefc fix: moves csv and excel exports to client 2025-09-02 11:03:37 +05:30
pandeymangg
8cbf58fe8d fix: tests 2025-09-02 10:29:36 +05:30
pandeymangg
941098a3fa fix: coderabbit feedback 2025-09-01 13:46:55 +05:30
pandeymangg
8f7d5f8fd5 fix: api client unit test 2025-08-29 12:04:23 +05:30
pandeymangg
1e8c862d80 fix: uses storage package in apps/web 2025-08-29 11:47:46 +05:30
Anshuman Pandey
2502a6ce3c feat: storage package with s3 client (#6449) 2025-08-27 07:21:41 +05:30
pandeymangg
a709d04e8e updates cursor rules 2025-08-26 17:34:21 +05:30
pandeymangg
edc3b4172a updates cursor rules 2025-08-26 17:23:51 +05:30
pandeymangg
2ba079da68 feedback 2025-08-26 16:08:30 +05:30
pandeymangg
e1607def05 updates cursor rules 2025-08-26 15:26:05 +05:30
pandeymangg
9d7dac33be fix: batch size 2025-08-26 14:58:32 +05:30
pandeymangg
b9d544f36f fix: adds error handling 2025-08-26 12:39:13 +05:30
pandeymangg
7abd0e9aed adds pagination 2025-08-25 15:30:53 +05:30
pandeymangg
a9db89ecdd fix: rollback merge 2025-08-25 13:43:53 +05:30
pandeymangg
0155c41593 fix: adds deleteFilesByPrefix service 2025-08-25 12:42:18 +05:30
pandeymangg
df63f2e5d9 Merge branch 'main' into feat/storage-package 2025-08-25 11:42:57 +05:30
pandeymangg
7dd174ffea fix: adds maxSize 2025-08-21 22:10:33 +05:30
pandeymangg
7154f6fe74 fix: feedback 2025-08-21 21:53:03 +05:30
pandeymangg
f25f257f24 fix: jsdoc comments 2025-08-21 18:08:06 +05:30
pandeymangg
b945900fbf fix 2025-08-21 18:04:50 +05:30
pandeymangg
f8869e7522 reverts package versions 2025-08-21 18:02:05 +05:30
pandeymangg
886eb8598a fix: adds file existence check 2025-08-21 17:56:13 +05:30
pandeymangg
fe3c8e010f fix 2025-08-21 17:07:54 +05:30
pandeymangg
a6a76cc3cf adds cursor rules 2025-08-21 17:06:54 +05:30
pandeymangg
9e7a4e38cf feedback 2025-08-21 16:51:35 +05:30
pandeymangg
9cff5457d6 fixes 2025-08-20 17:28:05 +05:30
pandeymangg
a362455878 adds storage package 2025-08-20 12:23:39 +05:30
135 changed files with 9066 additions and 4122 deletions

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. description: >
alwaysApply: false 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 # Formbricks Database Schema Reference

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)).

View File

@@ -62,9 +62,6 @@ SMTP_PASSWORD=smtpPassword
# Uncomment the variables you would like to use and customize the values. # Uncomment the variables you would like to use and customize the values.
# Custom local storage path for file uploads
#UPLOADS_DIR=
############## ##############
# S3 STORAGE # # S3 STORAGE #
############## ##############

View File

@@ -55,6 +55,18 @@ jobs:
--health-interval=10s --health-interval=10s
--health-timeout=5s --health-timeout=5s
--health-retries=5 --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: steps:
- name: Harden the runner (Audit all outbound calls) - name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
@@ -92,8 +104,50 @@ jobs:
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
echo "" >> .env echo "" >> .env
echo "E2E_TESTING=1" >> .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 shell: bash
- name: Install MinIO client (mc)
run: |
wget -qO mc https://dl.min.io/client/mc/release/linux-amd64/mc
chmod +x mc
sudo mv mc /usr/local/bin/mc
- name: Wait for MinIO and create S3 bucket
run: |
echo "Waiting for MinIO to be ready..."
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"
break
fi
echo "Waiting for MinIO... (attempt ${i}/60)"
sleep 1
done
# Give MinIO a bit more time to fully initialize
sleep 5
echo "Configuring MinIO client..."
mc alias set local http://localhost:9000 minioadmin minioadmin
echo "Creating S3 bucket..."
mc mb --ignore-existing local/formbricks-e2e
echo "Verifying bucket creation..."
mc ls local/
echo "Testing MinIO connectivity..."
curl -fsS http://localhost:9000/minio/health/live && echo "✓ MinIO health check passed"
echo "Testing S3 API endpoint..."
curl -fsS http://localhost:9000/ && echo "✓ MinIO S3 API accessible" || echo "✗ MinIO S3 API not accessible"
- name: Build App - name: Build App
run: | run: |
pnpm build --filter=@formbricks/web... pnpm build --filter=@formbricks/web...
@@ -136,6 +190,22 @@ jobs:
sleep 10 sleep 10
done done
- name: Test Storage and MinIO Integration
run: |
echo "Testing MinIO file upload with mc client..."
# Test file upload using MinIO client
echo "test content" > test-file.txt
mc cp test-file.txt local/formbricks-e2e/test-file.txt
echo "Verifying file was uploaded..."
mc ls local/formbricks-e2e/
echo "Testing file download..."
mc cp local/formbricks-e2e/test-file.txt downloaded-test-file.txt
cat downloaded-test-file.txt
echo "MinIO integration test completed successfully!"
- name: Install Playwright - name: Install Playwright
run: pnpm exec playwright install --with-deps run: pnpm exec playwright install --with-deps

View File

@@ -11,6 +11,8 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags"; import { TTag } from "@formbricks/types/tags";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
vi.mock("@sentry/nextjs", () => ({ captureException: vi.fn() }));
// Mock react-hot-toast // Mock react-hot-toast
vi.mock("react-hot-toast", () => ({ vi.mock("react-hot-toast", () => ({
default: { default: {
@@ -158,6 +160,11 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", (
getResponsesDownloadUrlAction: vi.fn(), getResponsesDownloadUrlAction: vi.fn(),
})); }));
// Mock handleFileUpload
vi.mock("@/modules/storage/file-upload", () => ({
handleFileUpload: vi.fn(),
}));
vi.mock("@/modules/analysis/components/SingleResponseCard/actions", () => ({ vi.mock("@/modules/analysis/components/SingleResponseCard/actions", () => ({
deleteResponseAction: vi.fn(), deleteResponseAction: vi.fn(),
})); }));
@@ -192,6 +199,9 @@ vi.mock("@tolgee/react", () => ({
}), }),
})); }));
// Global mock anchor for tests
let globalMockAnchor: any;
// Define mock data for tests // Define mock data for tests
const mockProps = { const mockProps = {
data: [ data: [
@@ -232,23 +242,94 @@ beforeEach(() => {
// Reset all toast mocks before each test // Reset all toast mocks before each test
vi.mocked(toast.error).mockClear(); vi.mocked(toast.error).mockClear();
vi.mocked(toast.success).mockClear(); vi.mocked(toast.success).mockClear();
vi.mocked(getResponsesDownloadUrlAction).mockClear();
// Create a mock anchor element for download tests // Create a mock anchor element for download tests
const mockAnchor = { globalMockAnchor = {
href: "", href: "",
click: vi.fn(), click: vi.fn(),
style: {}, 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 // Update how we mock the document methods to avoid infinite recursion
const originalCreateElement = document.createElement.bind(document); const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tagName) => { vi.spyOn(document, "createElement").mockImplementation((tagName) => {
if (tagName === "a") return mockAnchor as any; if (tagName === "a") return globalMockAnchor as any;
return originalCreateElement(tagName); return originalCreateElement(tagName);
}); });
vi.spyOn(document.body, "appendChild").mockReturnValue(null as any); vi.spyOn(document.body, "appendChild").mockReturnValue(null as any);
vi.spyOn(document.body, "removeChild").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 // Cleanup after each test
@@ -259,6 +340,7 @@ afterEach(() => {
} }
cleanup(); cleanup();
vi.restoreAllMocks(); // Restore mocks after each test vi.restoreAllMocks(); // Restore mocks after each test
vi.unstubAllGlobals(); // Restore global stubs after each test
}); });
describe("ResponseTable", () => { describe("ResponseTable", () => {
@@ -313,52 +395,64 @@ describe("ResponseTable", () => {
test("calls downloadSelectedRows with csv format when toolbar button is clicked", async () => { test("calls downloadSelectedRows with csv format when toolbar button is clicked", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({ 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"); const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: 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"); const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton); await userEvent.click(downloadCsvButton);
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({ await waitFor(() => {
surveyId: "survey1", expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
format: "csv", surveyId: "survey1",
filterCriteria: { responseIds: [] }, format: "csv",
}); filterCriteria: { responseIds: [] },
});
// Check if link was created and clicked // Check if link was created and clicked
expect(document.createElement).toHaveBeenCalledWith("a"); expect(document.createElement).toHaveBeenCalledWith("a");
const mockLink = document.createElement("a"); expect(globalMockAnchor.href).toBe("https://download.url/file.csv");
expect(mockLink.href).toBe("https://download.url/file.csv"); expect(document.body.appendChild).toHaveBeenCalled();
expect(document.body.appendChild).toHaveBeenCalled(); expect(globalMockAnchor.click).toHaveBeenCalled();
expect(mockLink.click).toHaveBeenCalled(); expect(document.body.removeChild).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled(); });
}); });
test("calls downloadSelectedRows with xlsx format when toolbar button is clicked", async () => { test("calls downloadSelectedRows with xlsx format when toolbar button is clicked", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({ 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"); const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: 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"); const downloadXlsxButton = screen.getByTestId("download-xlsx");
await userEvent.click(downloadXlsxButton); await userEvent.click(downloadXlsxButton);
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({ await waitFor(() => {
surveyId: "survey1", expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
format: "xlsx", surveyId: "survey1",
filterCriteria: { responseIds: [] }, format: "xlsx",
}); filterCriteria: { responseIds: [] },
});
// Check if link was created and clicked // Check if link was created and clicked
expect(document.createElement).toHaveBeenCalledWith("a"); expect(document.createElement).toHaveBeenCalledWith("a");
const mockLink = document.createElement("a"); expect(globalMockAnchor.href).toBe("https://download.url/file.xlsx");
expect(mockLink.href).toBe("https://download.url/file.xlsx"); expect(document.body.appendChild).toHaveBeenCalled();
expect(document.body.appendChild).toHaveBeenCalled(); expect(globalMockAnchor.click).toHaveBeenCalled();
expect(mockLink.click).toHaveBeenCalled(); expect(document.body.removeChild).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled(); });
}); });
// Test response modal // Test response modal

View File

@@ -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 { 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 { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; 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 { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { import {
@@ -106,6 +107,7 @@ export const ResponseTable = ({
() => (isFetchingFirstPage ? Array(10).fill({}) : data), () => (isFetchingFirstPage ? Array(10).fill({}) : data),
[data, isFetchingFirstPage] [data, isFetchingFirstPage]
); );
const tableColumns = useMemo( const tableColumns = useMemo(
() => () =>
isFetchingFirstPage isFetchingFirstPage
@@ -192,13 +194,7 @@ export const ResponseTable = ({
}); });
if (downloadResponse?.data) { if (downloadResponse?.data) {
const link = document.createElement("a"); downloadResponsesFile(downloadResponse.data.fileName, downloadResponse.data.fileContents, format);
link.href = downloadResponse.data;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else { } else {
toast.error(t("environments.surveys.responses.error_downloading_responses")); toast.error(t("environments.surveys.responses.error_downloading_responses"));
} }

View File

@@ -1,8 +1,6 @@
"use server"; "use server";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate"; 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 { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; 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 csvContent = await convertToCsv(csvHeaders, csvData);
const fileName = `personal-links-${parsedInput.surveyId}-${Date.now()}.csv`; 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 { return {
downloadUrl,
fileName, fileName,
count: csvData.length, csvContent,
}; };
}); });

View File

@@ -19,7 +19,7 @@ vi.mock("./QuestionSummaryHeader", () => ({
})); }));
// Mock utility functions // Mock utility functions
vi.mock("@/lib/storage/utils", () => ({ vi.mock("@/modules/storage/utils", () => ({
getOriginalFileNameFromUrl: (url: string) => `original-${url.split("/").pop()}`, getOriginalFileNameFromUrl: (url: string) => `original-${url.split("/").pop()}`,
})); }));

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { getOriginalFileNameFromUrl } from "@/lib/storage/utils";
import { timeSince } from "@/lib/time"; import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
import { PersonAvatar } from "@/modules/ui/components/avatars"; import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";

View File

@@ -122,7 +122,21 @@ export const PersonalLinksTab = ({
}); });
if (result?.data) { 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"), { toast.success(t("environments.surveys.share.personal_links.links_generated_success_toast"), {
duration: 5000, duration: 5000,
id: "generating-links", id: "generating-links",
@@ -134,6 +148,7 @@ export const PersonalLinksTab = ({
id: "generating-links", id: "generating-links",
}); });
} }
setIsGenerating(false); setIsGenerating(false);
}; };

View File

@@ -1,7 +1,7 @@
"use server"; "use server";
import { getOrganization } from "@/lib/organization/service"; 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 { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service"; import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
@@ -43,7 +43,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({ const ZGetSurveyFilterDataAction = z.object({

View File

@@ -5,8 +5,8 @@ import {
useResponseFilter, useResponseFilter,
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions"; 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 { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside"; import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
import { Calendar } from "@/modules/ui/components/calendar"; import { Calendar } from "@/modules/ui/components/calendar";
import { import {
@@ -16,6 +16,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { cn } from "@/modules/ui/lib/utils"; import { cn } from "@/modules/ui/lib/utils";
import * as Sentry from "@sentry/nextjs";
import { TFnType, useTranslate } from "@tolgee/react"; import { TFnType, useTranslate } from "@tolgee/react";
import { import {
differenceInDays, differenceInDays,
@@ -238,29 +239,32 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
setSelectingDate(DateSelected.FROM); setSelectingDate(DateSelected.FROM);
}; };
const handleDownloadResponses = async (filter: FilterDownload, filetype: "csv" | "xlsx") => { const handleDownloadResponses = async (filter: FilterDownload, fileType: "csv" | "xlsx") => {
const responseFilters = filter === FilterDownload.ALL ? {} : filters; try {
setIsDownloading(true); const responseFilters = filter === FilterDownload.ALL ? {} : filters;
setIsDownloading(true);
const responsesDownloadUrlResponse = await getResponsesDownloadUrlAction({ const responsesDownloadUrlResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id, surveyId: survey.id,
format: filetype, format: fileType,
filterCriteria: responseFilters, filterCriteria: responseFilters,
}); });
if (responsesDownloadUrlResponse?.data) { if (responsesDownloadUrlResponse?.data) {
const link = document.createElement("a"); downloadResponsesFile(
link.href = responsesDownloadUrlResponse.data; responsesDownloadUrlResponse.data.fileName,
link.download = ""; responsesDownloadUrlResponse.data.fileContents,
document.body.appendChild(link); fileType
link.click(); );
document.body.removeChild(link); } else {
} else { toast.error(t("environments.surveys.responses.error_downloading_responses"));
const errorMessage = getFormattedErrorMessage(responsesDownloadUrlResponse); }
toast.error(errorMessage); } catch (err) {
Sentry.captureException(err);
toast.error(t("environments.surveys.responses.error_downloading_responses"));
} finally {
setIsDownloading(false);
} }
setIsDownloading(false);
}; };
useClickOutside(datePickerRef, () => handleDatePickerClose()); useClickOutside(datePickerRef, () => handleDatePickerClose());

View File

@@ -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);
};

View File

@@ -2,10 +2,10 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines"; import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { getResponse, updateResponse } from "@/lib/response/service"; import { getResponse, updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question"; import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { validateFileUploads } from "@/modules/storage/utils";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";

View File

@@ -2,10 +2,10 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines"; import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer"; import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { UAParser } from "ua-parser-js"; import { UAParser } from "ua-parser-js";

View File

@@ -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",
});
});
});

View File

@@ -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");
}
};

View File

@@ -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"),
};
}
},
});

View File

@@ -1,13 +1,16 @@
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; 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 { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey } from "@/lib/survey/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 { 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 { NextRequest } from "next/server";
import { ZUploadFileRequest } from "@formbricks/types/storage"; import { logger } from "@formbricks/logger";
import { uploadPrivateFile } from "./lib/uploadPrivateFile"; import { TUploadPrivateFileRequest, ZUploadPrivateFileRequest } from "@formbricks/types/storage";
interface Context { interface Context {
params: Promise<{ 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 // uploaded files will be private, only the user who has access to the environment can access the file
// uploading private files requires no authentication // uploading private files requires no authentication
// use this to let users upload files to a survey for example // use this to let users upload files to a file upload question response 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
export const POST = withV1ApiWrapper({ export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => { handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
const params = await props.params; const params = await props.params;
const environmentId = params.environmentId; const { environmentId } = params;
let jsonInput: TUploadPrivateFileRequest;
const jsonInput = await req.json(); try {
const inputValidation = ZUploadFileRequest.safeParse({ 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, ...jsonInput,
environmentId, environmentId,
}); });
if (!inputValidation.success) { if (!parsedInputResult.success) {
const errorDetails = transformErrorToDetails(parsedInputResult.error);
logger.error({ error: errorDetails }, "Fields are missing or incorrectly formatted");
return { return {
response: responses.badRequestResponse( response: responses.badRequestResponse(
"Invalid request", "Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error), errorDetails,
true true
), ),
}; };
} }
const { fileName, fileType, surveyId } = inputValidation.data; const { fileName, fileType, surveyId } = parsedInputResult.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 [survey, organization] = await Promise.all([ const [survey, organization] = await Promise.all([
getSurvey(surveyId), 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 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 { return {
response: await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed), response: responses.successResponse(signedUrlResponse.data),
}; };
}, },
customRateLimitConfig: rateLimitConfigs.storage.upload,
}); });

View File

@@ -2,10 +2,10 @@ import { handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation";
import { deleteResponse, getResponse, updateResponse } from "@/lib/response/service"; import { deleteResponse, getResponse, updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { ZResponseUpdateInput } from "@formbricks/types/responses"; import { ZResponseUpdateInput } from "@formbricks/types/responses";

View File

@@ -1,10 +1,10 @@
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation";
import { getResponses } from "@/lib/response/service"; import { getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors"; import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";

View File

@@ -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" });
});
});

View File

@@ -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");
}
};

View File

@@ -4,7 +4,7 @@ import { hasPermission } from "@/modules/organization/settings/api-keys/lib/util
import { Session } from "next-auth"; import { Session } from "next-auth";
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { TAuthenticationApiKey } from "@formbricks/types/auth"; import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { checkAuth, checkForRequiredFields } from "./utils"; import { checkAuth } from "./utils";
// Create mock response objects // Create mock response objects
const mockBadRequestResponse = new Response("Bad Request", { status: 400 }); 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", () => { describe("checkAuth", () => {
const environmentId = "env-123"; const environmentId = "env-123";

View File

@@ -3,24 +3,6 @@ import { TApiV1Authentication } from "@/app/lib/api/with-api-logging";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth"; import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; 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) => { export const checkAuth = async (authentication: TApiV1Authentication, environmentId: string) => {
if (!authentication) { if (!authentication) {
return responses.notAuthenticatedResponse(); return responses.notAuthenticatedResponse();

View File

@@ -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"),
};
}
},
});

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 { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; 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 { NextRequest } from "next/server";
import { logger } from "@formbricks/logger"; 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 // uploaded files will be public, anyone can access the file
// uploading public files requires authentication // uploading public files requires authentication
// use this to upload files for a specific resource, e.g. a user profile picture or a survey // use this to get a signed url for uploading a public file for a specific resource, e.g. a survey's background image
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
export const POST = withV1ApiWrapper({ export const POST = withV1ApiWrapper({
handler: async ({ req, authentication }: { req: NextRequest; authentication: TApiV1Authentication }) => { handler: async ({ req, authentication }: { req: NextRequest; authentication: TApiV1Authentication }) => {
let storageInput; let storageInput: TUploadPublicFileRequest;
try { try {
storageInput = await req.json(); 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 { 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); const authResponse = await checkAuth(authentication, environmentId);
if (authResponse) { if (authResponse) {
return { return {
@@ -41,28 +52,19 @@ export const POST = withV1ApiWrapper({
}; };
} }
// Perform server-side file validation first to block dangerous file types const signedUrlResponse = await getSignedUrlForUpload(fileName, environmentId, fileType, "public");
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) { if (!signedUrlResponse.ok) {
logger.error({ error: signedUrlResponse.error }, "Error getting signed url for upload");
const errorResponse = getErrorResponseFromStorageError(signedUrlResponse.error, { fileName });
return { 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 { return {
response: await getSignedUrlForPublicFile(fileName, environmentId, fileType), response: responses.successResponse(signedUrlResponse.data),
}; };
}, },
customRateLimitConfig: rateLimitConfigs.storage.upload,
}); });

View File

@@ -1,3 +0,0 @@
import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/local/route";
export { OPTIONS, POST };

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 { authOptions } from "@/modules/auth/lib/authOptions";
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers"; import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; 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 { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; import { TAuditAction, TAuditTarget, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
@@ -37,6 +38,7 @@ export interface TWithV1ApiWrapperParams<TResult extends { response: Response },
handler: (params: THandlerParams<TProps>) => Promise<TResult>; handler: (params: THandlerParams<TProps>) => Promise<TResult>;
action?: TAuditAction; action?: TAuditAction;
targetType?: TAuditTarget; targetType?: TAuditTarget;
customRateLimitConfig?: TRateLimitConfig;
} }
enum ApiV1RouteTypeEnum { enum ApiV1RouteTypeEnum {
@@ -48,13 +50,13 @@ enum ApiV1RouteTypeEnum {
/** /**
* Apply client-side API rate limiting (IP-based or sync-specific) * 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); const syncEndpoint = isSyncWithUserIdentificationEndpoint(url);
if (syncEndpoint) { if (syncEndpoint) {
const syncRateLimitConfig = rateLimitConfigs.api.syncUserIdentification; const syncRateLimitConfig = rateLimitConfigs.api.syncUserIdentification;
await applyRateLimit(syncRateLimitConfig, syncEndpoint.userId); await applyRateLimit(syncRateLimitConfig, syncEndpoint.userId);
} else { } 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 ( const handleRateLimiting = async (
url: string, url: string,
authentication: TApiV1Authentication, authentication: TApiV1Authentication,
routeType: ApiV1RouteTypeEnum routeType: ApiV1RouteTypeEnum,
customRateLimitConfig?: TRateLimitConfig
): Promise<Response | null> => { ): Promise<Response | null> => {
try { try {
if (authentication) { if (authentication) {
if ("user" in authentication) { if ("user" in authentication) {
// Session-based authentication for integration routes // 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) { } else if ("hashedApiKey" in authentication) {
// API key authentication for general routes // API key authentication for general routes
await applyRateLimit(rateLimitConfigs.api.v1, authentication.hashedApiKey); await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.hashedApiKey);
} else { } else {
logger.error({ authentication }, "Unknown authentication type"); logger.error({ authentication }, "Unknown authentication type");
return responses.internalServerErrorResponse("Invalid authentication configuration"); return responses.internalServerErrorResponse("Invalid authentication configuration");
@@ -81,7 +84,7 @@ const handleRateLimiting = async (
} }
if (routeType === ApiV1RouteTypeEnum.Client) { if (routeType === ApiV1RouteTypeEnum.Client) {
await applyClientRateLimit(url); await applyClientRateLimit(url, customRateLimitConfig);
} }
} catch (error) { } catch (error) {
return responses.tooManyRequestsResponse(error.message); return responses.tooManyRequestsResponse(error.message);
@@ -282,7 +285,7 @@ export const withV1ApiWrapper: {
} = <TResult extends { response: Response }, TProps = unknown>( } = <TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps> params: TWithV1ApiWrapperParams<TResult, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => { ): ((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> => { return async (req: NextRequest, props: TProps): Promise<Response> => {
// === Audit Log Setup === // === Audit Log Setup ===
const saveAuditLog = action && targetType; const saveAuditLog = action && targetType;
@@ -312,7 +315,12 @@ export const withV1ApiWrapper: {
// === Rate Limiting === // === Rate Limiting ===
if (isRateLimited) { 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; if (rateLimitResponse) return rateLimitResponse;
} }

View File

@@ -1,146 +0,0 @@
export enum FileUploadError {
NO_FILE = "No file provided or invalid file type. Expected a File or Blob.",
INVALID_FILE_TYPE = "Please upload an image file.",
FILE_SIZE_EXCEEDED = "File size must be less than 10 MB.",
UPLOAD_FAILED = "Upload failed. Please try again.",
}
export const toBase64 = (file: File) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = reject;
});
export const handleFileUpload = async (
file: File,
environmentId: string,
allowedFileExtensions?: string[]
): Promise<{
error?: FileUploadError;
url: string;
}> => {
try {
if (!(file instanceof File)) {
return {
error: FileUploadError.NO_FILE,
url: "",
};
}
if (!file.type.startsWith("image/")) {
return { error: FileUploadError.INVALID_FILE_TYPE, url: "" };
}
const fileBuffer = await file.arrayBuffer();
const bufferBytes = fileBuffer.byteLength;
const bufferKB = bufferBytes / 1024;
if (bufferKB > 10240) {
return {
error: FileUploadError.FILE_SIZE_EXCEEDED,
url: "",
};
}
const payload = {
fileName: file.name,
fileType: file.type,
allowedFileExtensions,
environmentId,
};
const response = await fetch("/api/v1/management/storage", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
return {
error: FileUploadError.UPLOAD_FAILED,
url: "",
};
}
const json = await response.json();
const { data } = json;
const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data;
let localUploadDetails: Record<string, string> = {};
if (signingData) {
const { signature, timestamp, uuid } = signingData;
localUploadDetails = {
fileType: file.type,
fileName: encodeURIComponent(updatedFileName),
environmentId,
signature,
timestamp: String(timestamp),
uuid,
};
}
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,
}),
});
if (!uploadResponse.ok) {
return {
error: FileUploadError.UPLOAD_FAILED,
url: "",
};
}
return {
url: fileUrl,
};
} catch (error) {
console.error("Error in uploading file: ", error);
return {
error: FileUploadError.UPLOAD_FAILED,
url: "",
};
}
};

View File

@@ -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();
});
});

View File

@@ -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");
}
};

View File

@@ -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");
}
};

View File

@@ -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) {
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");
}
};

View File

@@ -1,24 +1,25 @@
import { authenticateRequest } from "@/app/api/v1/auth"; import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; 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 { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler"; import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { deleteFile, getSignedUrlForDownload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { type NextRequest } from "next/server"; import { type NextRequest } from "next/server";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { ZStorageRetrievalParams } from "@formbricks/types/storage"; import { TAccessType, ZDeleteFileRequest, ZDownloadFileRequest } from "@formbricks/types/storage";
import { getFile } from "./lib/get-file"; import { logFileDeletion } from "./lib/audit-logs";
export const GET = async ( export const GET = async (
request: NextRequest, request: NextRequest,
props: { params: Promise<{ environmentId: string; accessType: string; fileName: string }> } props: { params: Promise<{ environmentId: string; accessType: TAccessType; fileName: string }> }
): Promise<Response> => { ): Promise<Response> => {
const params = await props.params; const params = await props.params;
const paramValidation = ZStorageRetrievalParams.safeParse(params); const paramValidation = ZDownloadFileRequest.safeParse(params);
if (!paramValidation.success) { if (!paramValidation.success) {
return responses.badRequestResponse( return responses.badRequestResponse(
@@ -28,36 +29,49 @@ export const GET = async (
); );
} }
const { environmentId, accessType, fileName: fileNameOG } = params; const { environmentId, accessType, fileName } = paramValidation.data;
const fileName = decodeURIComponent(fileNameOG); // check auth
if (accessType === "private") {
const session = await getServerSession(authOptions);
if (accessType === "public") { if (!session?.user) {
return await getFile(environmentId, accessType, fileName); // check for api key auth
} const auth = await authenticateRequest(request);
// if the user is authenticated via the session if (!auth) {
return responses.notAuthenticatedResponse();
}
const session = await getServerSession(authOptions); if (!hasPermission(auth.environmentPermissions, environmentId, "GET")) {
return responses.unauthorizedResponse();
}
} else {
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!session?.user) { if (!isUserAuthorized) {
// check for api key auth return responses.unauthorizedResponse();
const res = await authenticateRequest(request); }
if (!res) {
return 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) { if (!signedUrlResult.ok) {
return responses.unauthorizedResponse(); 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 ( export const DELETE = async (
@@ -65,157 +79,101 @@ export const DELETE = async (
props: { params: Promise<{ environmentId: string; accessType: string; fileName: string }> } props: { params: Promise<{ environmentId: string; accessType: string; fileName: string }> }
): Promise<Response> => { ): Promise<Response> => {
const params = await props.params; const params = await props.params;
const paramValidation = ZDeleteFileRequest.safeParse(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 });
if (!paramValidation.success) { if (!paramValidation.success) {
const errorDetails = transformErrorToDetails(paramValidation.error);
await logFileDeletion({ await logFileDeletion({
failureReason: "Parameter validation failed", failureReason: "Parameter validation failed",
accessType, environmentId: params.environmentId,
apiUrl: request.url,
}); });
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted", return responses.badRequestResponse("Fields are missing or incorrectly formatted", errorDetails, true);
transformErrorToDetails(paramValidation.error),
true
);
} }
const { const { environmentId, accessType, fileName } = paramValidation.data;
environmentId: validEnvId,
accessType: validAccessType,
fileName: validFileName,
} = paramValidation.data;
// Authentication
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user) { if (!session?.user) {
await logFileDeletion({ // check for api key auth
failureReason: "User not authenticated", const auth = await authenticateRequest(request);
accessType: validAccessType,
});
return responses.notAuthenticatedResponse();
}
// Authorization if (!auth) {
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, validEnvId); await logFileDeletion({
if (!isUserAuthorized) { failureReason: "User not authenticated",
await logFileDeletion({ accessType,
failureReason: "User not authorized to access environment", environmentId,
accessType: validAccessType, apiUrl: request.url,
userId: session.user.id, });
}); return responses.notAuthenticatedResponse();
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 (!hasPermission(auth.environmentPermissions, environmentId, "DELETE")) {
await logFileDeletion({
failureReason: "User not authorized to access environment",
accessType,
environmentId,
apiUrl: request.url,
});
return responses.unauthorizedResponse();
}
try {
await applyRateLimit(rateLimitConfigs.storage.delete, auth.hashedApiKey);
} catch (error) {
return responses.tooManyRequestsResponse(error.message);
}
} else {
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isUserAuthorized) {
await logFileDeletion({
failureReason: "User not authorized to access environment",
accessType,
userId: session.user.id,
environmentId,
apiUrl: request.url,
});
return responses.unauthorizedResponse();
}
try {
await applyRateLimit(rateLimitConfigs.storage.delete, session.user.id);
} catch (error) {
return responses.tooManyRequestsResponse(error.message);
}
}
const deleteResult = await deleteFile(environmentId, accessType, decodeURIComponent(fileName));
const isSuccess = deleteResult.ok;
if (!isSuccess) {
logger.error({ error: deleteResult.error }, "Error deleting file");
await logFileDeletion({ await logFileDeletion({
status: isSuccess ? "success" : "failure", failureReason: deleteResult.error.code,
failureReason: isSuccess ? undefined : failureReason, accessType,
accessType: validAccessType, userId: session?.user?.id,
userId: session.user.id, environmentId,
apiUrl: request.url,
}); });
return deleteResult; const errorResponse = getErrorResponseFromStorageError(deleteResult.error, { fileName });
} catch (error) { return errorResponse;
await logFileDeletion({
failureReason: error instanceof Error ? error.message : "Unexpected error during file deletion",
accessType: validAccessType,
userId: session.user.id,
});
throw error;
} }
await logFileDeletion({
status: "success",
accessType,
userId: session?.user?.id,
environmentId,
apiUrl: request.url,
});
return responses.successResponse("File deleted successfully");
}; };

View File

@@ -110,20 +110,11 @@ export const S3_REGION = env.S3_REGION;
export const S3_ENDPOINT_URL = env.S3_ENDPOINT_URL; export const S3_ENDPOINT_URL = env.S3_ENDPOINT_URL;
export const S3_BUCKET_NAME = env.S3_BUCKET_NAME; export const S3_BUCKET_NAME = env.S3_BUCKET_NAME;
export const S3_FORCE_PATH_STYLE = env.S3_FORCE_PATH_STYLE === "1"; export const S3_FORCE_PATH_STYLE = env.S3_FORCE_PATH_STYLE === "1";
export const UPLOADS_DIR = env.UPLOADS_DIR ?? "./uploads"; export const MAX_FILE_UPLOAD_SIZES = {
export const MAX_SIZES = {
standard: 1024 * 1024 * 10, // 10MB standard: 1024 * 1024 * 10, // 10MB
big: 1024 * 1024 * 1024, // 1GB big: 1024 * 1024 * 1024, // 1GB
} as const; } 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;
};
// Colors for Survey Bg // Colors for Survey Bg
export const SURVEY_BG_COLORS = [ export const SURVEY_BG_COLORS = [
"#FFFFFF", "#FFFFFF",

View File

@@ -1,12 +1,6 @@
import { createCipheriv, randomBytes } from "crypto"; import { createCipheriv, randomBytes } from "crypto";
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { import { getHash, symmetricDecrypt, symmetricEncrypt } from "./crypto";
generateLocalSignedUrl,
getHash,
symmetricDecrypt,
symmetricEncrypt,
validateLocalSignedUrl,
} from "./crypto";
vi.mock("./constants", () => ({ ENCRYPTION_KEY: "0".repeat(32) })); vi.mock("./constants", () => ({ ENCRYPTION_KEY: "0".repeat(32) }));
@@ -44,16 +38,4 @@ describe("crypto", () => {
expect(typeof h).toBe("string"); expect(typeof h).toBe("string");
expect(h.length).toBeGreaterThan(0); 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
);
});
}); });

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 { logger } from "@formbricks/logger";
import { ENCRYPTION_KEY } from "./constants"; 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 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;
};

View File

@@ -112,7 +112,6 @@ export const env = createEnv({
TURNSTILE_SITE_KEY: z.string().optional(), TURNSTILE_SITE_KEY: z.string().optional(),
RECAPTCHA_SITE_KEY: z.string().optional(), RECAPTCHA_SITE_KEY: z.string().optional(),
RECAPTCHA_SECRET_KEY: z.string().optional(), RECAPTCHA_SECRET_KEY: z.string().optional(),
UPLOADS_DIR: z.string().min(1).optional(),
VERCEL_URL: z.string().optional(), VERCEL_URL: z.string().optional(),
WEBAPP_URL: z.string().url().optional(), WEBAPP_URL: z.string().url().optional(),
UNSPLASH_ACCESS_KEY: z.string().optional(), UNSPLASH_ACCESS_KEY: z.string().optional(),
@@ -214,7 +213,6 @@ export const env = createEnv({
RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY, RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,
RECAPTCHA_SECRET_KEY: process.env.RECAPTCHA_SECRET_KEY, RECAPTCHA_SECRET_KEY: process.env.RECAPTCHA_SECRET_KEY,
TERMS_URL: process.env.TERMS_URL, TERMS_URL: process.env.TERMS_URL,
UPLOADS_DIR: process.env.UPLOADS_DIR,
VERCEL_URL: process.env.VERCEL_URL, VERCEL_URL: process.env.VERCEL_URL,
WEBAPP_URL: process.env.WEBAPP_URL, WEBAPP_URL: process.env.WEBAPP_URL,
UNSPLASH_ACCESS_KEY: process.env.UNSPLASH_ACCESS_KEY, UNSPLASH_ACCESS_KEY: process.env.UNSPLASH_ACCESS_KEY,

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);
};

View File

@@ -1,4 +1,5 @@
import "server-only"; import "server-only";
import { deleteFile } from "@/modules/storage/service";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
import { z } from "zod"; import { z } from "zod";
@@ -16,9 +17,8 @@ import {
} from "@formbricks/types/responses"; } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags"; 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 { deleteDisplay } from "../display/service";
import { deleteFile, putFile } from "../storage/service";
import { getSurvey } from "../survey/service"; import { getSurvey } from "../survey/service";
import { convertToCsv, convertToXlsxBuffer } from "../utils/file-conversion"; import { convertToCsv, convertToXlsxBuffer } from "../utils/file-conversion";
import { validateInputs } from "../utils/validate"; import { validateInputs } from "../utils/validate";
@@ -302,11 +302,11 @@ export const getResponses = reactCache(
} }
); );
export const getResponseDownloadUrl = async ( export const getResponseDownloadFile = async (
surveyId: string, surveyId: string,
format: "csv" | "xlsx", format: "csv" | "xlsx",
filterCriteria?: TResponseFilterCriteria filterCriteria?: TResponseFilterCriteria
): Promise<string> => { ): Promise<{ fileContents: string; fileName: string }> => {
validateInputs([surveyId, ZId], [format, ZString], [filterCriteria, ZResponseFilterCriteria.optional()]); validateInputs([surveyId, ZId], [format, ZString], [filterCriteria, ZResponseFilterCriteria.optional()]);
try { try {
const survey = await getSurvey(surveyId); const survey = await getSurvey(surveyId);
@@ -315,9 +315,6 @@ export const getResponseDownloadUrl = async (
throw new ResourceNotFoundError("Survey", surveyId); throw new ResourceNotFoundError("Survey", surveyId);
} }
const environmentId = survey.environmentId;
const accessType = "private";
const batchSize = 3000; const batchSize = 3000;
// Use cursor-based pagination instead of count + offset to avoid expensive queries // Use cursor-based pagination instead of count + offset to avoid expensive queries
@@ -365,18 +362,19 @@ export const getResponseDownloadUrl = async (
const jsonData = getResponsesJson(survey, responses, questions, userAttributes, hiddenFields); const jsonData = getResponsesJson(survey, responses, questions, userAttributes, hiddenFields);
const fileName = getResponsesFileName(survey?.name || "", format); const fileName = getResponsesFileName(survey?.name || "", format);
let fileBuffer: Buffer; let fileContents: string;
if (format === "xlsx") { if (format === "xlsx") {
fileBuffer = convertToXlsxBuffer(headers, jsonData); const buffer = convertToXlsxBuffer(headers, jsonData);
fileContents = buffer.toString("base64");
} else { } else {
const csvFile = await convertToCsv(headers, jsonData); fileContents = await convertToCsv(headers, jsonData);
fileBuffer = Buffer.from(csvFile);
} }
await putFile(fileName, fileBuffer, accessType, environmentId); return {
fileContents,
return `${WEBAPP_URL}/storage/${environmentId}/${accessType}/${fileName}`; fileName,
};
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message); throw new DatabaseError(error.message);

View File

@@ -29,7 +29,7 @@ import {
getResponse, getResponse,
getResponseBySingleUseId, getResponseBySingleUseId,
getResponseCountBySurveyId, getResponseCountBySurveyId,
getResponseDownloadUrl, getResponseDownloadFile,
getResponsesByEnvironmentId, getResponsesByEnvironmentId,
updateResponse, updateResponse,
} from "../service"; } from "../service";
@@ -78,12 +78,12 @@ beforeEach(() => {
prisma.response.findMany.mockResolvedValue([mockResponse]); prisma.response.findMany.mockResolvedValue([mockResponse]);
prisma.response.delete.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.response.count.mockResolvedValue(1);
prisma.organization.findFirst.mockResolvedValue(mockOrganizationOutput); prisma.organization.findFirst.mockResolvedValue(mockOrganizationOutput as unknown as any);
prisma.organization.findUnique.mockResolvedValue(mockOrganizationOutput); prisma.organization.findUnique.mockResolvedValue(mockOrganizationOutput as unknown as any);
prisma.project.findMany.mockResolvedValue([]); prisma.project.findMany.mockResolvedValue([]);
// @ts-expect-error // @ts-expect-error
prisma.response.aggregate.mockResolvedValue({ _count: { id: 1 } }); prisma.response.aggregate.mockResolvedValue({ _count: { id: 1 } });
@@ -207,8 +207,8 @@ describe("Tests for getResponseDownloadUrl service", () => {
prisma.response.count.mockResolvedValue(1); prisma.response.count.mockResolvedValue(1);
prisma.response.findMany.mockResolvedValue([mockResponse]); prisma.response.findMany.mockResolvedValue([mockResponse]);
const url = await getResponseDownloadUrl(mockSurveyId, "csv"); const result = await getResponseDownloadFile(mockSurveyId, "csv");
const fileExtension = url.split(".").pop(); const fileExtension = result.fileName.split(".").pop();
expect(fileExtension).toEqual("csv"); expect(fileExtension).toEqual("csv");
}); });
@@ -217,22 +217,22 @@ describe("Tests for getResponseDownloadUrl service", () => {
prisma.response.count.mockResolvedValue(1); prisma.response.count.mockResolvedValue(1);
prisma.response.findMany.mockResolvedValue([mockResponse]); prisma.response.findMany.mockResolvedValue([mockResponse]);
const url = await getResponseDownloadUrl(mockSurveyId, "xlsx", { finished: true }); const result = await getResponseDownloadFile(mockSurveyId, "xlsx", { finished: true });
const fileExtension = url.split(".").pop(); const fileExtension = result.fileName.split(".").pop();
expect(fileExtension).toEqual("xlsx"); expect(fileExtension).toEqual("xlsx");
}); });
}); });
describe("Sad Path", () => { describe("Sad Path", () => {
testInputValidation(getResponseDownloadUrl, mockSurveyId, 123); testInputValidation(getResponseDownloadFile, mockSurveyId, 123);
test("Throws error if response file is of different format than expected", async () => { test("Throws error if response file is of different format than expected", async () => {
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput); prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
prisma.response.count.mockResolvedValue(1); prisma.response.count.mockResolvedValue(1);
prisma.response.findMany.mockResolvedValue([mockResponse]); prisma.response.findMany.mockResolvedValue([mockResponse]);
const url = await getResponseDownloadUrl(mockSurveyId, "csv", { finished: true }); const result = await getResponseDownloadFile(mockSurveyId, "csv", { finished: true });
const fileExtension = url.split(".").pop(); const fileExtension = result.fileName.split(".").pop();
expect(fileExtension).not.toEqual("xlsx"); expect(fileExtension).not.toEqual("xlsx");
}); });
@@ -245,7 +245,7 @@ describe("Tests for getResponseDownloadUrl service", () => {
prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput); prisma.survey.findUnique.mockResolvedValue(mockSurveyOutput);
prisma.response.findMany.mockRejectedValue(errToThrow); 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 () => { test("Throws DatabaseError on PrismaClientKnownRequestError, when the getResponses fails", async () => {
@@ -259,7 +259,7 @@ describe("Tests for getResponseDownloadUrl service", () => {
prisma.response.count.mockResolvedValue(1); prisma.response.count.mockResolvedValue(1);
prisma.response.findMany.mockRejectedValue(errToThrow); 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 () => { test("Throws a generic Error for unexpected problems", async () => {
@@ -268,7 +268,7 @@ describe("Tests for getResponseDownloadUrl service", () => {
// error from getSurvey // error from getSurvey
prisma.survey.findUnique.mockRejectedValue(new Error(mockErrorMessage)); prisma.survey.findUnique.mockRejectedValue(new Error(mockErrorMessage));
await expect(getResponseDownloadUrl(mockSurveyId, "xlsx")).rejects.toThrow(Error); await expect(getResponseDownloadFile(mockSurveyId, "xlsx")).rejects.toThrow(Error);
}); });
}); });
}); });

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"),
});
});
});
});

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;
}
};

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();
});
});
});

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");
}
};

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 { beforeEach, describe, expect, test, vi } from "vitest";
import { InvalidInputError } from "@formbricks/types/errors"; import { InvalidInputError } from "@formbricks/types/errors";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";

View File

@@ -1,5 +1,5 @@
import "server-only"; import "server-only";
import { isValidImageFile } from "@/lib/fileValidation"; import { isValidImageFile } from "@/modules/storage/utils";
import { InvalidInputError } from "@formbricks/types/errors"; import { InvalidInputError } from "@formbricks/types/errors";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSegment } from "@formbricks/types/segment"; import { TSegment } from "@formbricks/types/segment";

View File

@@ -15,12 +15,16 @@ export const convertToCsv = async (fields: string[], jsonData: Record<string, st
logger.error(err, "Failed to convert to CSV"); logger.error(err, "Failed to convert to CSV");
throw new Error("Failed to convert to CSV"); throw new Error("Failed to convert to CSV");
} }
return 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 wb = xlsx.utils.book_new();
const ws = xlsx.utils.json_to_sheet(jsonData, { header: fields }); const ws = xlsx.utils.json_to_sheet(jsonData, { header: fields });
xlsx.utils.book_append_sheet(wb, ws, "Sheet1"); 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" });
}; };

View File

@@ -125,6 +125,7 @@
"add_filter": "Filter hinzufügen", "add_filter": "Filter hinzufügen",
"add_logo": "Logo hinzufügen", "add_logo": "Logo hinzufügen",
"add_member": "Mitglied hinzufügen", "add_member": "Mitglied hinzufügen",
"add_new_project": "Neues Projekt hinzufügen",
"add_project": "Projekt hinzufügen", "add_project": "Projekt hinzufügen",
"add_to_team": "Zum Team hinzufügen", "add_to_team": "Zum Team hinzufügen",
"all": "Alle", "all": "Alle",
@@ -149,6 +150,9 @@
"cancel": "Abbrechen", "cancel": "Abbrechen",
"centered_modal": "Zentriertes Modalfenster", "centered_modal": "Zentriertes Modalfenster",
"choices": "Entscheidungen", "choices": "Entscheidungen",
"choose_environment": "Umgebung auswählen",
"choose_organization": "Organisation auswählen",
"choose_project": "Projekt wählen",
"clear_all": "Alles löschen", "clear_all": "Alles löschen",
"clear_filters": "Filter löschen", "clear_filters": "Filter löschen",
"clear_selection": "Auswahl aufheben", "clear_selection": "Auswahl aufheben",
@@ -177,7 +181,6 @@
"created_at": "Erstellt am", "created_at": "Erstellt am",
"created_by": "Erstellt von", "created_by": "Erstellt von",
"customer_success": "Kundenerfolg", "customer_success": "Kundenerfolg",
"danger_zone": "Gefahrenzone",
"dark_overlay": "Dunkle Überlagerung", "dark_overlay": "Dunkle Überlagerung",
"date": "Datum", "date": "Datum",
"default": "Standard", "default": "Standard",
@@ -201,6 +204,10 @@
"environment_not_found": "Umgebung nicht gefunden", "environment_not_found": "Umgebung nicht gefunden",
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.", "environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
"error": "Fehler", "error": "Fehler",
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
"error_component_title": "Fehler beim Laden der Ressourcen",
"error_rate_limit_description": "Maximale Anzahl an Anfragen erreicht. Bitte später erneut versuchen.",
"error_rate_limit_title": "Rate Limit Überschritten",
"expand_rows": "Zeilen erweitern", "expand_rows": "Zeilen erweitern",
"finish": "Fertigstellen", "finish": "Fertigstellen",
"follow_these": "Folge diesen", "follow_these": "Folge diesen",
@@ -232,11 +239,9 @@
"label": "Bezeichnung", "label": "Bezeichnung",
"language": "Sprache", "language": "Sprache",
"learn_more": "Mehr erfahren", "learn_more": "Mehr erfahren",
"license": "Lizenz",
"light_overlay": "Helle Überlagerung", "light_overlay": "Helle Überlagerung",
"limits_reached": "Limits erreicht", "limits_reached": "Limits erreicht",
"link": "Link", "link": "Link",
"link_and_email": "Link & E-Mail",
"link_survey": "Link-Umfrage", "link_survey": "Link-Umfrage",
"link_surveys": "Umfragen verknüpfen", "link_surveys": "Umfragen verknüpfen",
"load_more": "Mehr laden", "load_more": "Mehr laden",
@@ -283,6 +288,7 @@
"organization": "Organisation", "organization": "Organisation",
"organization_id": "Organisations-ID", "organization_id": "Organisations-ID",
"organization_not_found": "Organisation nicht gefunden", "organization_not_found": "Organisation nicht gefunden",
"organization_settings": "Organisationseinstellungen",
"organization_teams_not_found": "Organisations-Teams nicht gefunden", "organization_teams_not_found": "Organisations-Teams nicht gefunden",
"other": "Andere", "other": "Andere",
"others": "Andere", "others": "Andere",
@@ -306,7 +312,7 @@
"product_manager": "Produktmanager", "product_manager": "Produktmanager",
"profile": "Profil", "profile": "Profil",
"profile_id": "Profil-ID", "profile_id": "Profil-ID",
"project_configuration": "Projektkonfiguration", "project_configuration": "Projekteinstellungen",
"project_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.", "project_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
"project_id": "Projekt-ID", "project_id": "Projekt-ID",
"project_name": "Projektname", "project_name": "Projektname",
@@ -378,7 +384,6 @@
"survey_scheduled": "Umfrage geplant.", "survey_scheduled": "Umfrage geplant.",
"survey_type": "Umfragetyp", "survey_type": "Umfragetyp",
"surveys": "Umfragen", "surveys": "Umfragen",
"switch_organization": "Organisation wechseln",
"switch_to": "Wechseln zu {environment}", "switch_to": "Wechseln zu {environment}",
"table_items_deleted_successfully": "{type}s erfolgreich gelöscht", "table_items_deleted_successfully": "{type}s erfolgreich gelöscht",
"table_settings": "Tabelleinstellungen", "table_settings": "Tabelleinstellungen",
@@ -576,8 +581,6 @@
"contacts_table_refresh": "Kontakte aktualisieren", "contacts_table_refresh": "Kontakte aktualisieren",
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert", "contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.", "delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
"first_name": "Vorname",
"last_name": "Nachname",
"no_responses_found": "Keine Antworten gefunden", "no_responses_found": "Keine Antworten gefunden",
"not_provided": "Nicht angegeben", "not_provided": "Nicht angegeben",
"search_contact": "Kontakt suchen", "search_contact": "Kontakt suchen",
@@ -989,7 +992,6 @@
"free": "Kostenlos", "free": "Kostenlos",
"free_description": "Unbegrenzte Umfragen, Teammitglieder und mehr.", "free_description": "Unbegrenzte Umfragen, Teammitglieder und mehr.",
"get_2_months_free": "2 Monate gratis", "get_2_months_free": "2 Monate gratis",
"get_in_touch": "Kontaktiere uns",
"hosted_in_frankfurt": "Gehostet in Frankfurt", "hosted_in_frankfurt": "Gehostet in Frankfurt",
"ios_android_sdks": "iOS & Android SDK für mobile Umfragen", "ios_android_sdks": "iOS & Android SDK für mobile Umfragen",
"link_surveys": "Umfragen verlinken (teilbar)", "link_surveys": "Umfragen verlinken (teilbar)",

View File

@@ -125,6 +125,7 @@
"add_filter": "Add filter", "add_filter": "Add filter",
"add_logo": "Add logo", "add_logo": "Add logo",
"add_member": "Add member", "add_member": "Add member",
"add_new_project": "Add new project",
"add_project": "Add project", "add_project": "Add project",
"add_to_team": "Add to team", "add_to_team": "Add to team",
"all": "All", "all": "All",
@@ -149,6 +150,9 @@
"cancel": "Cancel", "cancel": "Cancel",
"centered_modal": "Centered Modal", "centered_modal": "Centered Modal",
"choices": "Choices", "choices": "Choices",
"choose_environment": "Choose environment",
"choose_organization": "Choose organization",
"choose_project": "Choose project",
"clear_all": "Clear all", "clear_all": "Clear all",
"clear_filters": "Clear filters", "clear_filters": "Clear filters",
"clear_selection": "Clear selection", "clear_selection": "Clear selection",
@@ -177,7 +181,6 @@
"created_at": "Created at", "created_at": "Created at",
"created_by": "Created by", "created_by": "Created by",
"customer_success": "Customer Success", "customer_success": "Customer Success",
"danger_zone": "Danger Zone",
"dark_overlay": "Dark overlay", "dark_overlay": "Dark overlay",
"date": "Date", "date": "Date",
"default": "Default", "default": "Default",
@@ -201,6 +204,10 @@
"environment_not_found": "Environment not found", "environment_not_found": "Environment not found",
"environment_notice": "You're currently in the {environment} environment.", "environment_notice": "You're currently in the {environment} environment.",
"error": "Error", "error": "Error",
"error_component_description": "This resource doesn't exist or you don't have the necessary rights to access it.",
"error_component_title": "Error loading resources",
"error_rate_limit_description": "Maximum number of requests reached. Please try again later.",
"error_rate_limit_title": "Rate Limit Exceeded",
"expand_rows": "Expand rows", "expand_rows": "Expand rows",
"finish": "Finish", "finish": "Finish",
"follow_these": "Follow these", "follow_these": "Follow these",
@@ -232,11 +239,9 @@
"label": "Label", "label": "Label",
"language": "Language", "language": "Language",
"learn_more": "Learn more", "learn_more": "Learn more",
"license": "License",
"light_overlay": "Light overlay", "light_overlay": "Light overlay",
"limits_reached": "Limits Reached", "limits_reached": "Limits Reached",
"link": "Link", "link": "Link",
"link_and_email": "Link & Email",
"link_survey": "Link Survey", "link_survey": "Link Survey",
"link_surveys": "Link Surveys", "link_surveys": "Link Surveys",
"load_more": "Load more", "load_more": "Load more",
@@ -283,6 +288,7 @@
"organization": "Organization", "organization": "Organization",
"organization_id": "Organization ID", "organization_id": "Organization ID",
"organization_not_found": "Organization not found", "organization_not_found": "Organization not found",
"organization_settings": "Organization settings",
"organization_teams_not_found": "Organization teams not found", "organization_teams_not_found": "Organization teams not found",
"other": "Other", "other": "Other",
"others": "Others", "others": "Others",
@@ -306,7 +312,7 @@
"product_manager": "Product Manager", "product_manager": "Product Manager",
"profile": "Profile", "profile": "Profile",
"profile_id": "Profile ID", "profile_id": "Profile ID",
"project_configuration": "Project's Configuration", "project_configuration": "Project Configuration",
"project_creation_description": "Organize surveys in projects for better access control.", "project_creation_description": "Organize surveys in projects for better access control.",
"project_id": "Project ID", "project_id": "Project ID",
"project_name": "Project Name", "project_name": "Project Name",
@@ -378,7 +384,6 @@
"survey_scheduled": "Survey scheduled.", "survey_scheduled": "Survey scheduled.",
"survey_type": "Survey Type", "survey_type": "Survey Type",
"surveys": "Surveys", "surveys": "Surveys",
"switch_organization": "Switch organization",
"switch_to": "Switch to {environment}", "switch_to": "Switch to {environment}",
"table_items_deleted_successfully": "{type}s deleted successfully", "table_items_deleted_successfully": "{type}s deleted successfully",
"table_settings": "Table settings", "table_settings": "Table settings",
@@ -576,8 +581,6 @@
"contacts_table_refresh": "Refresh contacts", "contacts_table_refresh": "Refresh contacts",
"contacts_table_refresh_success": "Contacts refreshed successfully", "contacts_table_refresh_success": "Contacts refreshed successfully",
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.", "delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.",
"first_name": "First Name",
"last_name": "Last Name",
"no_responses_found": "No responses found", "no_responses_found": "No responses found",
"not_provided": "Not provided", "not_provided": "Not provided",
"search_contact": "Search contact", "search_contact": "Search contact",
@@ -767,7 +770,7 @@
"check_out_the_docs": "Check out the docs.", "check_out_the_docs": "Check out the docs.",
"dive_into_the_docs": "Dive into the docs.", "dive_into_the_docs": "Dive into the docs.",
"does_your_widget_work": "Does your widget work?", "does_your_widget_work": "Does your widget work?",
"environment_id": "Your EnvironmentId", "environment_id": "Your Environment ID",
"environment_id_description": "This id uniquely identifies this Formbricks environment.", "environment_id_description": "This id uniquely identifies this Formbricks environment.",
"environment_id_description_with_environment_id": "Used to identify the correct environment: {environmentId} is yours.", "environment_id_description_with_environment_id": "Used to identify the correct environment: {environmentId} is yours.",
"formbricks_sdk": "Formbricks SDK", "formbricks_sdk": "Formbricks SDK",
@@ -989,7 +992,6 @@
"free": "Free", "free": "Free",
"free_description": "Unlimited Surveys, Team Members, and more.", "free_description": "Unlimited Surveys, Team Members, and more.",
"get_2_months_free": "Get 2 months free", "get_2_months_free": "Get 2 months free",
"get_in_touch": "Get in touch",
"hosted_in_frankfurt": "Hosted in Frankfurt", "hosted_in_frankfurt": "Hosted in Frankfurt",
"ios_android_sdks": "iOS & Android SDK for mobile surveys", "ios_android_sdks": "iOS & Android SDK for mobile surveys",
"link_surveys": "Link Surveys (Shareable)", "link_surveys": "Link Surveys (Shareable)",

View File

@@ -125,6 +125,7 @@
"add_filter": "Ajouter un filtre", "add_filter": "Ajouter un filtre",
"add_logo": "Ajouter un logo", "add_logo": "Ajouter un logo",
"add_member": "Ajouter un membre", "add_member": "Ajouter un membre",
"add_new_project": "Ajouter un nouveau projet",
"add_project": "Ajouter un projet", "add_project": "Ajouter un projet",
"add_to_team": "Ajouter à l'équipe", "add_to_team": "Ajouter à l'équipe",
"all": "Tout", "all": "Tout",
@@ -149,6 +150,9 @@
"cancel": "Annuler", "cancel": "Annuler",
"centered_modal": "Modal centré", "centered_modal": "Modal centré",
"choices": "Choix", "choices": "Choix",
"choose_environment": "Choisir l'environnement",
"choose_organization": "Choisir l'organisation",
"choose_project": "Choisir projet",
"clear_all": "Tout effacer", "clear_all": "Tout effacer",
"clear_filters": "Effacer les filtres", "clear_filters": "Effacer les filtres",
"clear_selection": "Effacer la sélection", "clear_selection": "Effacer la sélection",
@@ -177,7 +181,6 @@
"created_at": "Créé le", "created_at": "Créé le",
"created_by": "Créé par", "created_by": "Créé par",
"customer_success": "Succès Client", "customer_success": "Succès Client",
"danger_zone": "Zone de danger",
"dark_overlay": "Superposition sombre", "dark_overlay": "Superposition sombre",
"date": "Date", "date": "Date",
"default": "Par défaut", "default": "Par défaut",
@@ -201,6 +204,10 @@
"environment_not_found": "Environnement non trouvé", "environment_not_found": "Environnement non trouvé",
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.", "environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
"error": "Erreur", "error": "Erreur",
"error_component_description": "Cette ressource n'existe pas ou vous n'avez pas les droits nécessaires pour y accéder.",
"error_component_title": "Erreur de chargement des ressources",
"error_rate_limit_description": "Nombre maximal de demandes atteint. Veuillez réessayer plus tard.",
"error_rate_limit_title": "Limite de Taux Dépassée",
"expand_rows": "Développer les lignes", "expand_rows": "Développer les lignes",
"finish": "Terminer", "finish": "Terminer",
"follow_these": "Suivez ceci", "follow_these": "Suivez ceci",
@@ -232,11 +239,9 @@
"label": "Étiquette", "label": "Étiquette",
"language": "Langue", "language": "Langue",
"learn_more": "En savoir plus", "learn_more": "En savoir plus",
"license": "Licence",
"light_overlay": "Superposition légère", "light_overlay": "Superposition légère",
"limits_reached": "Limites atteints", "limits_reached": "Limites atteints",
"link": "Lien", "link": "Lien",
"link_and_email": "Liens et e-mail",
"link_survey": "Enquête de lien", "link_survey": "Enquête de lien",
"link_surveys": "Sondages de lien", "link_surveys": "Sondages de lien",
"load_more": "Charger plus", "load_more": "Charger plus",
@@ -283,6 +288,7 @@
"organization": "Organisation", "organization": "Organisation",
"organization_id": "ID de l'organisation", "organization_id": "ID de l'organisation",
"organization_not_found": "Organisation non trouvée", "organization_not_found": "Organisation non trouvée",
"organization_settings": "Paramètres de l'organisation",
"organization_teams_not_found": "Équipes d'organisation non trouvées", "organization_teams_not_found": "Équipes d'organisation non trouvées",
"other": "Autre", "other": "Autre",
"others": "Autres", "others": "Autres",
@@ -378,7 +384,6 @@
"survey_scheduled": "Sondage programmé.", "survey_scheduled": "Sondage programmé.",
"survey_type": "Type de sondage", "survey_type": "Type de sondage",
"surveys": "Enquêtes", "surveys": "Enquêtes",
"switch_organization": "Changer d'organisation",
"switch_to": "Passer à {environment}", "switch_to": "Passer à {environment}",
"table_items_deleted_successfully": "{type}s supprimés avec succès", "table_items_deleted_successfully": "{type}s supprimés avec succès",
"table_settings": "Réglages de table", "table_settings": "Réglages de table",
@@ -576,8 +581,6 @@
"contacts_table_refresh": "Rafraîchir les contacts", "contacts_table_refresh": "Rafraîchir les contacts",
"contacts_table_refresh_success": "Contacts rafraîchis avec succès", "contacts_table_refresh_success": "Contacts rafraîchis avec succès",
"delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.", "delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.",
"first_name": "Prénom",
"last_name": "Nom de famille",
"no_responses_found": "Aucune réponse trouvée", "no_responses_found": "Aucune réponse trouvée",
"not_provided": "Non fourni", "not_provided": "Non fourni",
"search_contact": "Rechercher un contact", "search_contact": "Rechercher un contact",
@@ -989,7 +992,6 @@
"free": "Gratuit", "free": "Gratuit",
"free_description": "Sondages illimités, membres d'équipe, et plus encore.", "free_description": "Sondages illimités, membres d'équipe, et plus encore.",
"get_2_months_free": "Obtenez 2 mois gratuits", "get_2_months_free": "Obtenez 2 mois gratuits",
"get_in_touch": "Prenez contact",
"hosted_in_frankfurt": "Hébergé à Francfort", "hosted_in_frankfurt": "Hébergé à Francfort",
"ios_android_sdks": "SDK iOS et Android pour les sondages mobiles", "ios_android_sdks": "SDK iOS et Android pour les sondages mobiles",
"link_surveys": "Sondages par lien (partageables)", "link_surveys": "Sondages par lien (partageables)",

2867
apps/web/locales/ja-JP.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -125,6 +125,7 @@
"add_filter": "Adicionar filtro", "add_filter": "Adicionar filtro",
"add_logo": "Adicionar logo", "add_logo": "Adicionar logo",
"add_member": "Adicionar membro", "add_member": "Adicionar membro",
"add_new_project": "Adicionar novo projeto",
"add_project": "Adicionar projeto", "add_project": "Adicionar projeto",
"add_to_team": "Adicionar à equipe", "add_to_team": "Adicionar à equipe",
"all": "Todos", "all": "Todos",
@@ -149,6 +150,9 @@
"cancel": "Cancelar", "cancel": "Cancelar",
"centered_modal": "Modal Centralizado", "centered_modal": "Modal Centralizado",
"choices": "Escolhas", "choices": "Escolhas",
"choose_environment": "Escolher ambiente",
"choose_organization": "Escolher organização",
"choose_project": "Escolher projeto",
"clear_all": "Limpar tudo", "clear_all": "Limpar tudo",
"clear_filters": "Limpar filtros", "clear_filters": "Limpar filtros",
"clear_selection": "Limpar seleção", "clear_selection": "Limpar seleção",
@@ -177,7 +181,6 @@
"created_at": "Data de criação", "created_at": "Data de criação",
"created_by": "Criado por", "created_by": "Criado por",
"customer_success": "Sucesso do Cliente", "customer_success": "Sucesso do Cliente",
"danger_zone": "Zona de Perigo",
"dark_overlay": "sobreposição escura", "dark_overlay": "sobreposição escura",
"date": "Encontro", "date": "Encontro",
"default": "Padrão", "default": "Padrão",
@@ -201,6 +204,10 @@
"environment_not_found": "Ambiente não encontrado", "environment_not_found": "Ambiente não encontrado",
"environment_notice": "Você está atualmente no ambiente {environment}.", "environment_notice": "Você está atualmente no ambiente {environment}.",
"error": "Erro", "error": "Erro",
"error_component_description": "Esse recurso não existe ou você não tem permissão para acessá-lo.",
"error_component_title": "Erro ao carregar recursos",
"error_rate_limit_description": "Número máximo de requisições atingido. Por favor, tente novamente mais tarde.",
"error_rate_limit_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas", "expand_rows": "Expandir linhas",
"finish": "Terminar", "finish": "Terminar",
"follow_these": "Siga esses", "follow_these": "Siga esses",
@@ -232,11 +239,9 @@
"label": "Etiqueta", "label": "Etiqueta",
"language": "Língua", "language": "Língua",
"learn_more": "Saiba mais", "learn_more": "Saiba mais",
"license": "Licença",
"light_overlay": "sobreposição leve", "light_overlay": "sobreposição leve",
"limits_reached": "Limites Atingidos", "limits_reached": "Limites Atingidos",
"link": "link", "link": "link",
"link_and_email": "Link & E-mail",
"link_survey": "Pesquisa de Link", "link_survey": "Pesquisa de Link",
"link_surveys": "Link de Pesquisas", "link_surveys": "Link de Pesquisas",
"load_more": "Carregar mais", "load_more": "Carregar mais",
@@ -283,6 +288,7 @@
"organization": "organização", "organization": "organização",
"organization_id": "ID da Organização", "organization_id": "ID da Organização",
"organization_not_found": "Organização não encontrada", "organization_not_found": "Organização não encontrada",
"organization_settings": "Configurações da Organização",
"organization_teams_not_found": "Equipes da organização não encontradas", "organization_teams_not_found": "Equipes da organização não encontradas",
"other": "outro", "other": "outro",
"others": "Outros", "others": "Outros",
@@ -378,7 +384,6 @@
"survey_scheduled": "Pesquisa agendada.", "survey_scheduled": "Pesquisa agendada.",
"survey_type": "Tipo de Pesquisa", "survey_type": "Tipo de Pesquisa",
"surveys": "Pesquisas", "surveys": "Pesquisas",
"switch_organization": "Mudar organização",
"switch_to": "Mudar para {environment}", "switch_to": "Mudar para {environment}",
"table_items_deleted_successfully": "{type}s deletados com sucesso", "table_items_deleted_successfully": "{type}s deletados com sucesso",
"table_settings": "Arrumação da mesa", "table_settings": "Arrumação da mesa",
@@ -576,8 +581,6 @@
"contacts_table_refresh": "Atualizar contatos", "contacts_table_refresh": "Atualizar contatos",
"contacts_table_refresh_success": "Contatos atualizados com sucesso", "contacts_table_refresh_success": "Contatos atualizados com sucesso",
"delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.", "delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
"first_name": "Primeiro Nome",
"last_name": "Sobrenome",
"no_responses_found": "Nenhuma resposta encontrada", "no_responses_found": "Nenhuma resposta encontrada",
"not_provided": "Não fornecido", "not_provided": "Não fornecido",
"search_contact": "Buscar contato", "search_contact": "Buscar contato",
@@ -989,7 +992,6 @@
"free": "grátis", "free": "grátis",
"free_description": "Pesquisas ilimitadas, membros da equipe e mais.", "free_description": "Pesquisas ilimitadas, membros da equipe e mais.",
"get_2_months_free": "Ganhe 2 meses grátis", "get_2_months_free": "Ganhe 2 meses grátis",
"get_in_touch": "Entre em contato",
"hosted_in_frankfurt": "Hospedado em Frankfurt", "hosted_in_frankfurt": "Hospedado em Frankfurt",
"ios_android_sdks": "SDK para iOS e Android para pesquisas móveis", "ios_android_sdks": "SDK para iOS e Android para pesquisas móveis",
"link_surveys": "Link de Pesquisas (Compartilhável)", "link_surveys": "Link de Pesquisas (Compartilhável)",

View File

@@ -125,6 +125,7 @@
"add_filter": "Adicionar filtro", "add_filter": "Adicionar filtro",
"add_logo": "Adicionar logótipo", "add_logo": "Adicionar logótipo",
"add_member": "Adicionar membro", "add_member": "Adicionar membro",
"add_new_project": "Adicionar novo projeto",
"add_project": "Adicionar projeto", "add_project": "Adicionar projeto",
"add_to_team": "Adicionar à equipa", "add_to_team": "Adicionar à equipa",
"all": "Todos", "all": "Todos",
@@ -149,6 +150,9 @@
"cancel": "Cancelar", "cancel": "Cancelar",
"centered_modal": "Modal Centralizado", "centered_modal": "Modal Centralizado",
"choices": "Escolhas", "choices": "Escolhas",
"choose_environment": "Escolha o ambiente",
"choose_organization": "Escolher organização",
"choose_project": "Escolher projeto",
"clear_all": "Limpar tudo", "clear_all": "Limpar tudo",
"clear_filters": "Limpar filtros", "clear_filters": "Limpar filtros",
"clear_selection": "Limpar seleção", "clear_selection": "Limpar seleção",
@@ -177,7 +181,6 @@
"created_at": "Criado em", "created_at": "Criado em",
"created_by": "Criado por", "created_by": "Criado por",
"customer_success": "Sucesso do Cliente", "customer_success": "Sucesso do Cliente",
"danger_zone": "Zona de Perigo",
"dark_overlay": "Sobreposição escura", "dark_overlay": "Sobreposição escura",
"date": "Data", "date": "Data",
"default": "Padrão", "default": "Padrão",
@@ -201,6 +204,10 @@
"environment_not_found": "Ambiente não encontrado", "environment_not_found": "Ambiente não encontrado",
"environment_notice": "Está atualmente no ambiente {environment}.", "environment_notice": "Está atualmente no ambiente {environment}.",
"error": "Erro", "error": "Erro",
"error_component_description": "Este recurso não existe ou não tem os direitos necessários para aceder a ele.",
"error_component_title": "Erro ao carregar recursos",
"error_rate_limit_description": "Número máximo de pedidos alcançado. Por favor, tente novamente mais tarde.",
"error_rate_limit_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas", "expand_rows": "Expandir linhas",
"finish": "Concluir", "finish": "Concluir",
"follow_these": "Siga estes", "follow_these": "Siga estes",
@@ -232,11 +239,9 @@
"label": "Etiqueta", "label": "Etiqueta",
"language": "Idioma", "language": "Idioma",
"learn_more": "Saiba mais", "learn_more": "Saiba mais",
"license": "Licença",
"light_overlay": "Sobreposição leve", "light_overlay": "Sobreposição leve",
"limits_reached": "Limites Atingidos", "limits_reached": "Limites Atingidos",
"link": "Link", "link": "Link",
"link_and_email": "Link e Email",
"link_survey": "Ligar Inquérito", "link_survey": "Ligar Inquérito",
"link_surveys": "Ligar Inquéritos", "link_surveys": "Ligar Inquéritos",
"load_more": "Carregar mais", "load_more": "Carregar mais",
@@ -283,6 +288,7 @@
"organization": "Organização", "organization": "Organização",
"organization_id": "ID da Organização", "organization_id": "ID da Organização",
"organization_not_found": "Organização não encontrada", "organization_not_found": "Organização não encontrada",
"organization_settings": "Configurações da Organização",
"organization_teams_not_found": "Equipas da organização não encontradas", "organization_teams_not_found": "Equipas da organização não encontradas",
"other": "Outro", "other": "Outro",
"others": "Outros", "others": "Outros",
@@ -378,7 +384,6 @@
"survey_scheduled": "Inquérito agendado.", "survey_scheduled": "Inquérito agendado.",
"survey_type": "Tipo de Inquérito", "survey_type": "Tipo de Inquérito",
"surveys": "Inquéritos", "surveys": "Inquéritos",
"switch_organization": "Mudar de organização",
"switch_to": "Mudar para {environment}", "switch_to": "Mudar para {environment}",
"table_items_deleted_successfully": "{type}s eliminados com sucesso", "table_items_deleted_successfully": "{type}s eliminados com sucesso",
"table_settings": "Configurações da tabela", "table_settings": "Configurações da tabela",
@@ -576,8 +581,6 @@
"contacts_table_refresh": "Atualizar contactos", "contacts_table_refresh": "Atualizar contactos",
"contacts_table_refresh_success": "Contactos atualizados com sucesso", "contacts_table_refresh_success": "Contactos atualizados com sucesso",
"delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.", "delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
"first_name": "Primeiro Nome",
"last_name": "Apelido",
"no_responses_found": "Nenhuma resposta encontrada", "no_responses_found": "Nenhuma resposta encontrada",
"not_provided": "Não fornecido", "not_provided": "Não fornecido",
"search_contact": "Procurar contacto", "search_contact": "Procurar contacto",
@@ -767,7 +770,7 @@
"check_out_the_docs": "Consulte a documentação.", "check_out_the_docs": "Consulte a documentação.",
"dive_into_the_docs": "Mergulhe na documentação.", "dive_into_the_docs": "Mergulhe na documentação.",
"does_your_widget_work": "O seu widget funciona?", "does_your_widget_work": "O seu widget funciona?",
"environment_id": "O seu EnvironmentId", "environment_id": "O Seu ID de Ambiente",
"environment_id_description": "Este id identifica de forma única este ambiente Formbricks.", "environment_id_description": "Este id identifica de forma única este ambiente Formbricks.",
"environment_id_description_with_environment_id": "Usado para identificar o ambiente correto: {environmentId} é o seu.", "environment_id_description_with_environment_id": "Usado para identificar o ambiente correto: {environmentId} é o seu.",
"formbricks_sdk": "SDK Formbricks", "formbricks_sdk": "SDK Formbricks",
@@ -989,7 +992,6 @@
"free": "Grátis", "free": "Grátis",
"free_description": "Inquéritos ilimitados, membros da equipa e mais.", "free_description": "Inquéritos ilimitados, membros da equipa e mais.",
"get_2_months_free": "Obtenha 2 meses grátis", "get_2_months_free": "Obtenha 2 meses grátis",
"get_in_touch": "Entre em contacto",
"hosted_in_frankfurt": "Hospedado em Frankfurt", "hosted_in_frankfurt": "Hospedado em Frankfurt",
"ios_android_sdks": "SDK iOS e Android para inquéritos móveis", "ios_android_sdks": "SDK iOS e Android para inquéritos móveis",
"link_surveys": "Ligar Inquéritos (Partilhável)", "link_surveys": "Ligar Inquéritos (Partilhável)",

View File

@@ -1,7 +1,7 @@
{ {
"auth": { "auth": {
"continue_with_azure": "Continuă cu Microsoft", "continue_with_azure": "Continuă cu Microsoft",
"continue_with_email": "Continuă cu Email", "continue_with_email": "Continuă cu email",
"continue_with_github": "Continuă cu GitHub", "continue_with_github": "Continuă cu GitHub",
"continue_with_google": "Continuă cu Google", "continue_with_google": "Continuă cu Google",
"continue_with_oidc": "Continuă cu {oidcDisplayName}", "continue_with_oidc": "Continuă cu {oidcDisplayName}",
@@ -49,7 +49,7 @@
"invite_not_found": "Invitația nu a fost găsită \uD83D\uDE25", "invite_not_found": "Invitația nu a fost găsită \uD83D\uDE25",
"invite_not_found_description": "Codul de invitație nu poate fi găsit sau a fost deja utilizat.", "invite_not_found_description": "Codul de invitație nu poate fi găsit sau a fost deja utilizat.",
"login": "Autentificare", "login": "Autentificare",
"welcome_to_organization": "Ești în \uD83C\uDF89", "welcome_to_organization": "Ai fost acceptat \uD83C\uDF89",
"welcome_to_organization_description": "Bun venit în organizație." "welcome_to_organization_description": "Bun venit în organizație."
}, },
"last_used": "Ultima utilizare", "last_used": "Ultima utilizare",
@@ -65,7 +65,7 @@
"new_to_formbricks": "Nou în Formbricks?", "new_to_formbricks": "Nou în Formbricks?",
"use_a_backup_code": "Folosiți un cod de rezervă" "use_a_backup_code": "Folosiți un cod de rezervă"
}, },
"saml_connection_error": "Ceva a mers prost. Vă rugăm să verificați consola aplicației pentru mai multe detalii.", "saml_connection_error": "Ceva nu a mers. Vă rugăm să verificați consola aplicației pentru mai multe detalii.",
"signup": { "signup": {
"captcha_failed": "Captcha eșuat", "captcha_failed": "Captcha eșuat",
"have_an_account": "Ai un cont?", "have_an_account": "Ai un cont?",
@@ -73,16 +73,16 @@
"password_validation_contain_at_least_1_number": "Conține cel puțin 1 număr", "password_validation_contain_at_least_1_number": "Conține cel puțin 1 număr",
"password_validation_minimum_8_and_maximum_128_characters": "Minim 8 & Maxim 128 caractere", "password_validation_minimum_8_and_maximum_128_characters": "Minim 8 & Maxim 128 caractere",
"password_validation_uppercase_and_lowercase": "Amestec de majuscule și minuscule", "password_validation_uppercase_and_lowercase": "Amestec de majuscule și minuscule",
"please_verify_captcha": "Vă rugăm să verificați reCAPTCHA", "please_verify_captcha": "Vă rugăm să verificați CAPTCHA",
"privacy_policy": "Politica de Confidențialitate", "privacy_policy": "Politica de confidențialitate",
"terms_of_service": "Termeni de Serviciu", "terms_of_service": "Termeni de utilizare a serviciului",
"title": "Creați-vă contul Formbricks" "title": "Creați-vă contul Formbricks"
}, },
"signup_without_verification_success": { "signup_without_verification_success": {
"user_successfully_created": "Utilizator creat cu succes", "user_successfully_created": "Utilizator creat cu succes",
"user_successfully_created_info": "Am verificat pentru un cont asociat cu {email}. Dacă nu a existat niciunul, am creat unul pentru tine. Dacă un cont deja exista, nu s-au făcut modificări. Vă rugăm să vă conectați mai jos pentru a continua." "user_successfully_created_info": "Am verificat pentru un cont asociat cu {email}. Dacă nu a existat niciunul, am creat unul pentru tine. Dacă un cont deja exista, nu s-au făcut modificări. Vă rugăm să vă conectați mai jos pentru a continua."
}, },
"testimonial_1": "\"Măsurăm claritatea documentațiilor noastre și învățăm din pierderi în folosirea aceleași platforme. Produs grozav, echipă foarte receptivă!\"", "testimonial_1": "\"Măsurăm claritatea documentațiilor noastre și învățăm din greșeli în folosirea platformei. Produs grozav, echipă foarte receptivă!\"",
"testimonial_all_features_included": "Toate funcționalitățile incluse", "testimonial_all_features_included": "Toate funcționalitățile incluse",
"testimonial_free_and_open_source": "Gratuit și open-source", "testimonial_free_and_open_source": "Gratuit și open-source",
"testimonial_no_credit_card_required": "Nu este necesar niciun card de credit", "testimonial_no_credit_card_required": "Nu este necesar niciun card de credit",
@@ -125,6 +125,7 @@
"add_filter": "Adăugați filtru", "add_filter": "Adăugați filtru",
"add_logo": "Adaugă logo", "add_logo": "Adaugă logo",
"add_member": "Adaugă membru", "add_member": "Adaugă membru",
"add_new_project": "Adaugă proiect nou",
"add_project": "Adaugă proiect", "add_project": "Adaugă proiect",
"add_to_team": "Adaugă la echipă", "add_to_team": "Adaugă la echipă",
"all": "Toate", "all": "Toate",
@@ -137,7 +138,7 @@
"anonymous": "Anonim", "anonymous": "Anonim",
"api_keys": "Chei API", "api_keys": "Chei API",
"app": "Aplicație", "app": "Aplicație",
"app_survey": "Sondaj Aplicație", "app_survey": "Sondaj aplicație",
"apply_filters": "Aplică filtre", "apply_filters": "Aplică filtre",
"are_you_sure": "Ești sigur?", "are_you_sure": "Ești sigur?",
"attributes": "Atribute", "attributes": "Atribute",
@@ -149,6 +150,9 @@
"cancel": "Anulare", "cancel": "Anulare",
"centered_modal": "Modală centralizată", "centered_modal": "Modală centralizată",
"choices": "Alegeri", "choices": "Alegeri",
"choose_environment": "Alege mediul",
"choose_organization": "Alege organizația",
"choose_project": "Alege proiectul",
"clear_all": "Șterge tot", "clear_all": "Șterge tot",
"clear_filters": "Curăță filtrele", "clear_filters": "Curăță filtrele",
"clear_selection": "Șterge selecția", "clear_selection": "Șterge selecția",
@@ -177,7 +181,6 @@
"created_at": "Creat la", "created_at": "Creat la",
"created_by": "Creat de", "created_by": "Creat de",
"customer_success": "Succesul Clientului", "customer_success": "Succesul Clientului",
"danger_zone": "Zonă periculoasă",
"dark_overlay": "Suprapunere întunecată", "dark_overlay": "Suprapunere întunecată",
"date": "Dată", "date": "Dată",
"default": "Implicit", "default": "Implicit",
@@ -201,6 +204,10 @@
"environment_not_found": "Mediul nu a fost găsit", "environment_not_found": "Mediul nu a fost găsit",
"environment_notice": "Te afli în prezent în mediul {environment}", "environment_notice": "Te afli în prezent în mediul {environment}",
"error": "Eroare", "error": "Eroare",
"error_component_description": "Această resursă nu există sau nu aveți drepturile necesare pentru a o accesa.",
"error_component_title": "Eroare la încărcarea resurselor",
"error_rate_limit_description": "Numărul maxim de cereri atins. Vă rugăm să încercați din nou mai târziu.",
"error_rate_limit_title": "Limită de cereri depășită",
"expand_rows": "Extinde rândurile", "expand_rows": "Extinde rândurile",
"finish": "Finalizează", "finish": "Finalizează",
"follow_these": "Urmați acestea", "follow_these": "Urmați acestea",
@@ -232,11 +239,9 @@
"label": "Etichetă", "label": "Etichetă",
"language": "Limba", "language": "Limba",
"learn_more": "Află mai multe", "learn_more": "Află mai multe",
"license": "Licență",
"light_overlay": "Suprapunere ușoară", "light_overlay": "Suprapunere ușoară",
"limits_reached": "Limite atinse", "limits_reached": "Limite atinse",
"link": "Legătura", "link": "Legătura",
"link_and_email": "Link & email",
"link_survey": "Conectează chestionarul", "link_survey": "Conectează chestionarul",
"link_surveys": "Conectează chestionarele", "link_surveys": "Conectează chestionarele",
"load_more": "Încarcă mai multe", "load_more": "Încarcă mai multe",
@@ -282,13 +287,14 @@
"or": "sau", "or": "sau",
"organization": "Organizație", "organization": "Organizație",
"organization_id": "ID Organizație", "organization_id": "ID Organizație",
"organization_not_found": "Organizație nu a fost găsită", "organization_not_found": "Organizația nu a fost găsită",
"organization_settings": "Setări Organizație",
"organization_teams_not_found": "Echipele organizației nu au fost găsite", "organization_teams_not_found": "Echipele organizației nu au fost găsite",
"other": "Altele", "other": "Altele",
"others": "Altele", "others": "Altele",
"overview": "Prezentare generală", "overview": "Prezentare generală",
"password": "Parolă", "password": "Parolă",
"paused": "Pauzat", "paused": "Pauză",
"pending_downgrade": "Reducere în aşteptare", "pending_downgrade": "Reducere în aşteptare",
"people_manager": "Manager de persoane", "people_manager": "Manager de persoane",
"person": "Persoană", "person": "Persoană",
@@ -306,7 +312,7 @@
"product_manager": "Manager de Produs", "product_manager": "Manager de Produs",
"profile": "Profil", "profile": "Profil",
"profile_id": "ID Profil", "profile_id": "ID Profil",
"project_configuration": "Configurarea Proiectului", "project_configuration": "Configurare proiect",
"project_creation_description": "Organizați sondajele în proiecte pentru un control mai bun al accesului.", "project_creation_description": "Organizați sondajele în proiecte pentru un control mai bun al accesului.",
"project_id": "ID proiect", "project_id": "ID proiect",
"project_name": "Nume proiect", "project_name": "Nume proiect",
@@ -324,7 +330,7 @@
"report_survey": "Raportează chestionarul", "report_survey": "Raportează chestionarul",
"request_pricing": "Solicită Prețuri", "request_pricing": "Solicită Prețuri",
"request_trial_license": "Solicitați o licență de încercare", "request_trial_license": "Solicitați o licență de încercare",
"reset_to_default": "Revină la implicit", "reset_to_default": "Revino la implicit",
"response": "Răspuns", "response": "Răspuns",
"responses": "Răspunsuri", "responses": "Răspunsuri",
"restart": "Repornește", "restart": "Repornește",
@@ -354,7 +360,7 @@
"share_feedback": "Împărtășește feedback", "share_feedback": "Împărtășește feedback",
"show": "Afișează", "show": "Afișează",
"show_response_count": "Afișează numărul de răspunsuri", "show_response_count": "Afișează numărul de răspunsuri",
"shown": "Arătat", "shown": "Afișat",
"size": "Mărime", "size": "Mărime",
"skipped": "Sărit", "skipped": "Sărit",
"skips": "Salturi", "skips": "Salturi",
@@ -362,7 +368,7 @@
"something_went_wrong": "Ceva nu a mers bine", "something_went_wrong": "Ceva nu a mers bine",
"something_went_wrong_please_try_again": "Ceva nu a mers bine. Vă rugăm să încercați din nou.", "something_went_wrong_please_try_again": "Ceva nu a mers bine. Vă rugăm să încercați din nou.",
"sort_by": "Sortare după", "sort_by": "Sortare după",
"start_free_trial": "Începe Perioada de Testare Gratuită", "start_free_trial": "Începe perioada de testare gratuită",
"status": "Stare", "status": "Stare",
"step_by_step_manual": "Manual pas cu pas", "step_by_step_manual": "Manual pas cu pas",
"styling": "Stilizare", "styling": "Stilizare",
@@ -378,7 +384,6 @@
"survey_scheduled": "Chestionar programat.", "survey_scheduled": "Chestionar programat.",
"survey_type": "Tip Chestionar", "survey_type": "Tip Chestionar",
"surveys": "Sondaje", "surveys": "Sondaje",
"switch_organization": "Comută organizația",
"switch_to": "Comută la {environment}", "switch_to": "Comută la {environment}",
"table_items_deleted_successfully": "\"{type} șterse cu succes\"", "table_items_deleted_successfully": "\"{type} șterse cu succes\"",
"table_settings": "Setări tabel", "table_settings": "Setări tabel",
@@ -433,15 +438,15 @@
"click_or_drag_to_upload_files": "Faceți clic sau trageți pentru a încărca fișiere.", "click_or_drag_to_upload_files": "Faceți clic sau trageți pentru a încărca fișiere.",
"email_customization_preview_email_heading": "Salut {userName}", "email_customization_preview_email_heading": "Salut {userName}",
"email_customization_preview_email_subject": "Previzualizare Personalizare Email Formbricks", "email_customization_preview_email_subject": "Previzualizare Personalizare Email Formbricks",
"email_customization_preview_email_text": "Acesta este un previzualizare a e-mailului pentru a vă arăta ce logo va fi afișat în e-mailurile.", "email_customization_preview_email_text": "Acesta este o previzualizare a emailului pentru a vă arăta ce logo va fi afișat în emailurile viitoare.",
"email_footer_text_1": "O zi minunată!", "email_footer_text_1": "O zi minunată!",
"email_footer_text_2": "Echipa Formbricks", "email_footer_text_2": "Echipa Formbricks",
"email_template_text_1": "Acest email a fost trimis prin Formbricks.", "email_template_text_1": "Acest email a fost trimis prin Formbricks.",
"embed_survey_preview_email_didnt_request": "Nu ați solicitat asta?", "embed_survey_preview_email_didnt_request": "Nu ați solicitat asta?",
"embed_survey_preview_email_environment_id": "ID de mediu", "embed_survey_preview_email_environment_id": "ID de mediu",
"embed_survey_preview_email_fight_spam": "Ajută-ne să combatem spam-ul și trimite acest e-mail la hola@formbricks.com", "embed_survey_preview_email_fight_spam": "Ajută-ne să combatem spam-ul și trimite acest e-mail la hola@formbricks.com",
"embed_survey_preview_email_heading": "Previzualizare Incorporare Email", "embed_survey_preview_email_heading": "Previzualizare încorporare email",
"embed_survey_preview_email_subject": "Previzualizare Chestionar Email Formbricks", "embed_survey_preview_email_subject": "Previzualizare chestionar email Formbricks",
"embed_survey_preview_email_text": "Așa arată fragmentul de cod încorporat într-un email:", "embed_survey_preview_email_text": "Așa arată fragmentul de cod încorporat într-un email:",
"forgot_password_email_change_password": "Schimbați parola", "forgot_password_email_change_password": "Schimbați parola",
"forgot_password_email_did_not_request": "Dacă nu ați solicitat acest lucru, vă rugăm să ignorați acest email.", "forgot_password_email_did_not_request": "Dacă nu ați solicitat acest lucru, vă rugăm să ignorați acest email.",
@@ -463,7 +468,7 @@
"password_changed_email_heading": "Parola modificată", "password_changed_email_heading": "Parola modificată",
"password_changed_email_text": "Parola dumneavoastră a fost schimbată cu succes.", "password_changed_email_text": "Parola dumneavoastră a fost schimbată cu succes.",
"password_reset_notify_email_subject": "Parola dumneavoastră Formbricks a fost schimbată", "password_reset_notify_email_subject": "Parola dumneavoastră Formbricks a fost schimbată",
"privacy_policy": "Politica de Confidențialitate", "privacy_policy": "Politica de confidențialitate",
"reject": "Respinge", "reject": "Respinge",
"render_email_response_value_file_upload_response_link_not_included": "Linkul către fișierul încărcat nu este inclus din motive de confidențialitate a datelor", "render_email_response_value_file_upload_response_link_not_included": "Linkul către fișierul încărcat nu este inclus din motive de confidențialitate a datelor",
"response_finished_email_subject": "Un răspuns pentru {surveyName} a fost finalizat ✅", "response_finished_email_subject": "Un răspuns pentru {surveyName} a fost finalizat ✅",
@@ -491,7 +496,7 @@
"verification_email_to_fill_survey": "Pentru a completa sondajul, vă rugăm să faceți clic pe butonul de mai jos:", "verification_email_to_fill_survey": "Pentru a completa sondajul, vă rugăm să faceți clic pe butonul de mai jos:",
"verification_email_verify_email": "Verifică emailul", "verification_email_verify_email": "Verifică emailul",
"verification_new_email_subject": "Verificare schimbare email", "verification_new_email_subject": "Verificare schimbare email",
"verification_security_notice": "Dacă nu ați cerut această modificare a e-mailului, vă rugăm să ignorați acest e-mail sau să contactați suportul imediat.", "verification_security_notice": "Dacă nu ați cerut această modificare a emailului, vă rugăm să ignorați acest email sau să contactați suportul imediat.",
"verified_link_survey_email_subject": "Chestionarul tău este gata să fie completat." "verified_link_survey_email_subject": "Chestionarul tău este gata să fie completat."
}, },
"environments": { "environments": {
@@ -500,7 +505,7 @@
"action_copy_failed": "Copierea acțiunii a eșuat", "action_copy_failed": "Copierea acțiunii a eșuat",
"action_created_successfully": "Acțiune creată cu succes", "action_created_successfully": "Acțiune creată cu succes",
"action_deleted_successfully": "Acțiune ștearsă cu succes.", "action_deleted_successfully": "Acțiune ștearsă cu succes.",
"action_type": "Tip Acțiune", "action_type": "Tip acțiune",
"action_updated_successfully": "Acțiune actualizată cu succes", "action_updated_successfully": "Acțiune actualizată cu succes",
"action_with_key_already_exists": "Acțiunea cu cheia {key} există deja", "action_with_key_already_exists": "Acțiunea cu cheia {key} există deja",
"action_with_name_already_exists": "Acțiunea cu numele {name} există deja", "action_with_name_already_exists": "Acțiunea cu numele {name} există deja",
@@ -563,7 +568,7 @@
"congrats": "Felicitări!", "congrats": "Felicitări!",
"connection_successful_message": "Bravo! Suntem conectați.", "connection_successful_message": "Bravo! Suntem conectați.",
"do_it_later": "Am să o fac mai târziu", "do_it_later": "Am să o fac mai târziu",
"finish_onboarding": "Încheie Înregistrarea", "finish_onboarding": "Încheie înregistrarea",
"headline": "Conectați aplicația sau site-ul dvs.", "headline": "Conectați aplicația sau site-ul dvs.",
"import_formbricks_and_initialize_the_widget_in_your_component": "Importați Formbricks și inițializați widgetul în componenta dumneavoastră (de exemplu, App.tsx):", "import_formbricks_and_initialize_the_widget_in_your_component": "Importați Formbricks și inițializați widgetul în componenta dumneavoastră (de exemplu, App.tsx):",
"insert_this_code_into_the_head_tag_of_your_website": "Introduceți acest cod în eticheta head a site-ului dvs.:", "insert_this_code_into_the_head_tag_of_your_website": "Introduceți acest cod în eticheta head a site-ului dvs.:",
@@ -576,12 +581,10 @@
"contacts_table_refresh": "Reîmprospătare contacte", "contacts_table_refresh": "Reîmprospătare contacte",
"contacts_table_refresh_success": "Contactele au fost actualizate cu succes", "contacts_table_refresh_success": "Contactele au fost actualizate cu succes",
"delete_contact_confirmation": "Acest lucru va șterge toate răspunsurile la sondaj și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute.", "delete_contact_confirmation": "Acest lucru va șterge toate răspunsurile la sondaj și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute.",
"first_name": "Prenume",
"last_name": "Nume de familie",
"no_responses_found": "Nu s-au găsit răspunsuri", "no_responses_found": "Nu s-au găsit răspunsuri",
"not_provided": "Neprovidat", "not_provided": "Nu a fost furnizat",
"search_contact": "Căutați contact", "search_contact": "Căutați contact",
"select_attribute": "Selectează Atributul", "select_attribute": "Selectează atributul",
"unlock_contacts_description": "Gestionează contactele și trimite sondaje țintite", "unlock_contacts_description": "Gestionează contactele și trimite sondaje țintite",
"unlock_contacts_title": "Deblocați contactele cu un plan superior.", "unlock_contacts_title": "Deblocați contactele cu un plan superior.",
"upload_contacts_modal_attributes_description": "Mapează coloanele din CSV-ul tău la atributele din Formbricks.", "upload_contacts_modal_attributes_description": "Mapează coloanele din CSV-ul tău la atributele din Formbricks.",
@@ -628,7 +631,7 @@
"connected_with_email": "Conectat cu {email}", "connected_with_email": "Conectat cu {email}",
"connecting_integration_failed_please_try_again": "Conectarea integrării a eșuat. Vă rugăm să încercați din nou!", "connecting_integration_failed_please_try_again": "Conectarea integrării a eșuat. Vă rugăm să încercați din nou!",
"create_survey_warning": "Trebuie să creezi un sondaj pentru a putea configura această integrare", "create_survey_warning": "Trebuie să creezi un sondaj pentru a putea configura această integrare",
"delete_integration": "Șterge Integrarea", "delete_integration": "Șterge integrarea",
"delete_integration_confirmation": "Sigur doriți să ștergeți această integrare?", "delete_integration_confirmation": "Sigur doriți să ștergeți această integrare?",
"google_sheet_integration_description": "Completați instantaneu foile de calcul cu datele chestionarului", "google_sheet_integration_description": "Completați instantaneu foile de calcul cu datele chestionarului",
"google_sheets": { "google_sheets": {
@@ -646,7 +649,7 @@
"no_integrations_yet": "Integrațiile tale Google Sheet vor apărea aici de îndată ce le vei adăuga. ⏲️", "no_integrations_yet": "Integrațiile tale Google Sheet vor apărea aici de îndată ce le vei adăuga. ⏲️",
"spreadsheet_url": "URL foaie de calcul" "spreadsheet_url": "URL foaie de calcul"
}, },
"include_created_at": "Include Data Creării", "include_created_at": "Include data creării",
"include_hidden_fields": "Include câmpuri ascunse", "include_hidden_fields": "Include câmpuri ascunse",
"include_metadata": "Includere Metadata (Browser, Țară, etc.)", "include_metadata": "Includere Metadata (Browser, Țară, etc.)",
"include_variables": "Include Variabile", "include_variables": "Include Variabile",
@@ -767,7 +770,7 @@
"check_out_the_docs": "Consultați documentația.", "check_out_the_docs": "Consultați documentația.",
"dive_into_the_docs": "Accesați documentația.", "dive_into_the_docs": "Accesați documentația.",
"does_your_widget_work": "Funcționează widgetul dvs.?", "does_your_widget_work": "Funcționează widgetul dvs.?",
"environment_id": "ID-ul Mediului Dvs.", "environment_id": "ID-ul mediului tău",
"environment_id_description": "Acest id identifică în mod unic acest mediu Formbricks.", "environment_id_description": "Acest id identifică în mod unic acest mediu Formbricks.",
"environment_id_description_with_environment_id": "Folosit pentru a identifica mediul corect: {environmentId} este al tău.", "environment_id_description_with_environment_id": "Folosit pentru a identifica mediul corect: {environmentId} este al tău.",
"formbricks_sdk": "SDK Formbricks", "formbricks_sdk": "SDK Formbricks",
@@ -971,52 +974,51 @@
"all_integrations": "Toate integrațiile", "all_integrations": "Toate integrațiile",
"annually": "Anual", "annually": "Anual",
"api_webhooks": "API & Webhook-uri", "api_webhooks": "API & Webhook-uri",
"app_surveys": "Sondaje de Aplicație", "app_surveys": "Sondaje în aplicație",
"attribute_based_targeting": "Targetare bazată pe atribute", "attribute_based_targeting": "Targetare bazată pe atribute",
"current": "Curent", "current": "Curent",
"current_plan": "Plan curent", "current_plan": "Plan curent",
"current_tier_limit": "Limită curentă a nivelului", "current_tier_limit": "Limită curentă a nivelului",
"custom": "Personalizat & Scalare", "custom": "Personalizat & Scalare",
"custom_contacts_limit": "Limit Personalizat Contacte", "custom_contacts_limit": "Limită personalizată contacte",
"custom_project_limit": "Limit Personalizat Proiect", "custom_project_limit": "Limit Personalizat Proiect",
"custom_response_limit": "Limit Personalizat Răspunsuri", "custom_response_limit": "Limit Personalizat Răspunsuri",
"email_embedded_surveys": "Sondaje încorporate în email", "email_embedded_surveys": "Sondaje încorporate în email",
"email_follow_ups": "Urmăriri Email", "email_follow_ups": "Email follow-up",
"enterprise_description": "Suport Premium și limite personalizate.", "enterprise_description": "Suport Premium și limite personalizate.",
"everybody_has_the_free_plan_by_default": "Toată lumea are planul gratuit implicit!", "everybody_has_the_free_plan_by_default": "Toată lumea are planul gratuit implicit!",
"everything_in_free": "Totul în Gratuit", "everything_in_free": "Totul în Gratuit",
"everything_in_startup": "Totul în Startup", "everything_in_startup": "Totul în Startup",
"free": "Gratuit", "free": "Gratuit",
"free_description": "Sondaje Nelimitate, Membri În Echipă și altele.", "free_description": "Sondaje nelimitate, membri în echipă și altele.",
"get_2_months_free": "Primește 2 luni gratuite", "get_2_months_free": "Primește 2 luni gratuite",
"get_in_touch": "Contactați-ne",
"hosted_in_frankfurt": "Găzduit în Frankfurt", "hosted_in_frankfurt": "Găzduit în Frankfurt",
"ios_android_sdks": "SDK iOS & Android pentru sondaje mobile", "ios_android_sdks": "SDK iOS & Android pentru sondaje mobile",
"link_surveys": "Sondaje Link (Distribuibil)", "link_surveys": "Sondaje Link (Distribuibil)",
"logic_jumps_hidden_fields_recurring_surveys": "Salturi Logice, Câmpuri Ascunse, Sondaje Recurente, etc.", "logic_jumps_hidden_fields_recurring_surveys": "Salturi Logice, Câmpuri Ascunse, Sondaje Recurente, etc.",
"manage_card_details": "Gestionați Detaliile Cardului", "manage_card_details": "Gestionați detaliile cardului",
"manage_subscription": "Gestionați Abonamentul", "manage_subscription": "Gestionați abonamentul",
"monthly": "Lunar", "monthly": "Lunar",
"monthly_identified_users": "Utilizatori Identificați Lunar", "monthly_identified_users": "Utilizatori identificați lunar",
"per_month": "pe lună", "per_month": "pe lună",
"per_year": "pe an", "per_year": "pe an",
"plan_upgraded_successfully": "Planul a fost upgradat cu succes", "plan_upgraded_successfully": "Planul a fost upgradat cu succes",
"premium_support_with_slas": "Suport premium cu SLA-uri", "premium_support_with_slas": "Suport premium cu SLA-uri",
"remove_branding": "Eliminare Branding", "remove_branding": "Eliminare branding",
"startup": "Pornire", "startup": "Pornire",
"startup_description": "Totul din versiunea gratuită cu funcții suplimentare.", "startup_description": "Totul din versiunea gratuită cu funcții suplimentare.",
"switch_plan": "Schimbă Planul", "switch_plan": "Schimbă planul",
"switch_plan_confirmation_text": "Sigur doriți să treceți la planul {plan}? Vi se va percepe {price} {period}.", "switch_plan_confirmation_text": "Sigur doriți să treceți la planul {plan}? Vi se va percepe {price} {period}.",
"team_access_roles": "Roluri Acces Echipă", "team_access_roles": "Roluri acces echipă",
"unable_to_upgrade_plan": "Nu se poate upgrada planul", "unable_to_upgrade_plan": "Nu se poate upgrada planul",
"unlimited_miu": "MIU Nelimitat", "unlimited_miu": "MIU Nelimitat",
"unlimited_projects": "Proiecte Nelimitate", "unlimited_projects": "Proiecte nelimitate",
"unlimited_responses": "Răspunsuri nelimitate", "unlimited_responses": "Răspunsuri nelimitate",
"unlimited_surveys": "Sondaje Nelimitate", "unlimited_surveys": "Sondaje nelimitate",
"unlimited_team_members": "Membri Nelimitați În Echipă", "unlimited_team_members": "Membri nelimitați în echipă",
"upgrade": "Actualizare", "upgrade": "Actualizare",
"uptime_sla_99": "Disponibilitate SLA (99%)", "uptime_sla_99": "Disponibilitate SLA (99%)",
"website_surveys": "Sondaje ale Site-ului" "website_surveys": "Sondaje ale site-ului"
}, },
"enterprise": { "enterprise": {
"audit_logs": "Jurnale de audit", "audit_logs": "Jurnale de audit",
@@ -1048,11 +1050,11 @@
"create_new_organization_description": "Creați o organizație nouă pentru a gestiona un alt set de proiecte.", "create_new_organization_description": "Creați o organizație nouă pentru a gestiona un alt set de proiecte.",
"customize_email_with_a_higher_plan": "Personalizați emailul cu un plan superior", "customize_email_with_a_higher_plan": "Personalizați emailul cu un plan superior",
"delete_member_confirmation": "Membrii șterși vor pierde accesul la toate proiectele și sondajele organizației tale.", "delete_member_confirmation": "Membrii șterși vor pierde accesul la toate proiectele și sondajele organizației tale.",
"delete_organization": "Șterge Organizație", "delete_organization": "Șterge organizație",
"delete_organization_description": "Șterge organizația cu toate proiectele ei, incluzând toate sondajele, răspunsurile, persoanele, acțiunile și atributele.", "delete_organization_description": "Șterge organizația cu toate proiectele ei, incluzând toate sondajele, răspunsurile, persoanele, acțiunile și atributele.",
"delete_organization_warning": "Înainte de a continua cu ștergerea acestei organizații, vă rugăm să fiți conștienți de următoarele consecințe:", "delete_organization_warning": "Înainte de a continua cu ștergerea acestei organizații, vă rugăm să fiți conștienți de următoarele consecințe:",
"delete_organization_warning_1": "Ștergerea permanentă a tuturor proiectelor legate de această organizație.", "delete_organization_warning_1": "Ștergerea permanentă a tuturor proiectelor legate de această organizație.",
"delete_organization_warning_2": "Această acțiune nu poate fi anulată. Dacă e dispărută, e dispărută.", "delete_organization_warning_2": "Această acțiune este ireversibilă",
"delete_organization_warning_3": "Vă rugăm să introduceți {organizationName} în câmpul următor pentru a confirma ștergerea definitivă a acestei organizații:", "delete_organization_warning_3": "Vă rugăm să introduceți {organizationName} în câmpul următor pentru a confirma ștergerea definitivă a acestei organizații:",
"eliminate_branding_with_whitelabel": "Eliminați brandingul Formbricks și activați opțiuni suplimentare de personalizare white-label.", "eliminate_branding_with_whitelabel": "Eliminați brandingul Formbricks și activați opțiuni suplimentare de personalizare white-label.",
"email_customization_preview_email_heading": "Salut {userName}", "email_customization_preview_email_heading": "Salut {userName}",
@@ -1074,7 +1076,7 @@
"manage_members_description": "Adăugați sau eliminați membri din organizația dvs.", "manage_members_description": "Adăugați sau eliminați membri din organizația dvs.",
"member_deleted_successfully": "Membru șters cu succes", "member_deleted_successfully": "Membru șters cu succes",
"member_invited_successfully": "Membru invitat cu succes", "member_invited_successfully": "Membru invitat cu succes",
"once_its_gone_its_gone": "Odată ce a dispărut, a dispărut.", "once_its_gone_its_gone": "Odată șters, nu va putea fi recuperat.",
"only_org_owner_can_perform_action": "Doar proprietarii organizației pot accesa această setare.", "only_org_owner_can_perform_action": "Doar proprietarii organizației pot accesa această setare.",
"organization_created_successfully": "Organizație creată cu succes!", "organization_created_successfully": "Organizație creată cu succes!",
"organization_deleted_successfully": "Organizație ștearsă cu succes!", "organization_deleted_successfully": "Organizație ștearsă cu succes!",
@@ -1090,7 +1092,7 @@
"remove_logo": "Înlătură siglă", "remove_logo": "Înlătură siglă",
"replace_logo": "Înlocuiește sigla", "replace_logo": "Înlocuiește sigla",
"resend_invitation_email": "Retrimite emailul de invitație", "resend_invitation_email": "Retrimite emailul de invitație",
"share_invite_link": "Distribuie Link-ul de Invitație", "share_invite_link": "Distribuie link-ul de invitație",
"share_this_link_to_let_your_organization_member_join_your_organization": "Distribuie acest link pentru a permite membrului organizației să se alăture organizației tale:", "share_this_link_to_let_your_organization_member_join_your_organization": "Distribuie acest link pentru a permite membrului organizației să se alăture organizației tale:",
"test_email_sent_successfully": "Email de test trimis cu succes", "test_email_sent_successfully": "Email de test trimis cu succes",
"use_multi_language_surveys_with_a_higher_plan": "Utilizați chestionare multilingve cu un plan superior", "use_multi_language_surveys_with_a_higher_plan": "Utilizați chestionare multilingve cu un plan superior",
@@ -1113,9 +1115,9 @@
"account_deletion_consequences_warning": "Consecințele ștergerii contului", "account_deletion_consequences_warning": "Consecințele ștergerii contului",
"backup_code": "Cod de rezervă", "backup_code": "Cod de rezervă",
"confirm_delete_account": "Șterge contul tău cu toate informațiile personale și datele tale", "confirm_delete_account": "Șterge contul tău cu toate informațiile personale și datele tale",
"confirm_delete_my_account": "Șterge Contul Meu", "confirm_delete_my_account": "Șterge contul meu",
"confirm_your_current_password_to_get_started": "Confirmaţi parola curentă pentru a începe.", "confirm_your_current_password_to_get_started": "Confirmaţi parola curentă pentru a începe.",
"delete_account": "Șterge Cont", "delete_account": "Șterge cont",
"disable_two_factor_authentication": "Dezactivează autentificarea în doi pași", "disable_two_factor_authentication": "Dezactivează autentificarea în doi pași",
"disable_two_factor_authentication_description": "Dacă este nevoie să dezactivați autentificarea în doi pași, vă recomandăm să o reactivați cât mai curând posibil.", "disable_two_factor_authentication_description": "Dacă este nevoie să dezactivați autentificarea în doi pași, vă recomandăm să o reactivați cât mai curând posibil.",
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Fiecare cod de rezervă poate fi utilizat o singură dată pentru a acorda acces fără autentificatorul tău.", "each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Fiecare cod de rezervă poate fi utilizat o singură dată pentru a acorda acces fără autentificatorul tău.",
@@ -1135,7 +1137,7 @@
"two_factor_authentication": "Autentificare în doi pași", "two_factor_authentication": "Autentificare în doi pași",
"two_factor_authentication_description": "Adăugați un strat suplimentar de securitate la contul dvs. în cazul în care parola este furată.", "two_factor_authentication_description": "Adăugați un strat suplimentar de securitate la contul dvs. în cazul în care parola este furată.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autentificare în doi pași activată. Introduceți codul de șase cifre din aplicația dvs. de autentificare.", "two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autentificare în doi pași activată. Introduceți codul de șase cifre din aplicația dvs. de autentificare.",
"two_factor_code": "Codul cu doi factori", "two_factor_code": "Codul pentru dublă autentificare",
"unlock_two_factor_authentication": "Deblocați autentificarea în doi pași cu un plan superior", "unlock_two_factor_authentication": "Deblocați autentificarea în doi pași cu un plan superior",
"update_personal_info": "Actualizează informațiile tale personale", "update_personal_info": "Actualizează informațiile tale personale",
"warning_cannot_delete_account": "Ești singurul proprietar al acestei organizații. Te rugăm să transferi proprietatea către un alt membru mai întâi.", "warning_cannot_delete_account": "Ești singurul proprietar al acestei organizații. Te rugăm să transferi proprietatea către un alt membru mai întâi.",
@@ -1188,7 +1190,7 @@
} }
}, },
"surveys": { "surveys": {
"all_set_time_to_create_first_survey": "Ești gata! Timp să creezi primul tău chestionar", "all_set_time_to_create_first_survey": "Ești gata! Este timpul să creezi primul tău chestionar",
"alphabetical": "Alfabetic", "alphabetical": "Alfabetic",
"copy_survey": "Copiază sondajul", "copy_survey": "Copiază sondajul",
"copy_survey_description": "Copiază acest sondaj într-un alt mediu", "copy_survey_description": "Copiază acest sondaj într-un alt mediu",
@@ -1277,7 +1279,7 @@
"caution_explanation_new_responses_separated": "Răspunsurile înainte de schimbare pot să nu fie sau să fie incluse doar parțial în rezumatul sondajului.", "caution_explanation_new_responses_separated": "Răspunsurile înainte de schimbare pot să nu fie sau să fie incluse doar parțial în rezumatul sondajului.",
"caution_explanation_only_new_responses_in_summary": "Toate datele, inclusiv răspunsurile anterioare, rămân disponibile ca descărcare pe pagina de rezumat a sondajului.", "caution_explanation_only_new_responses_in_summary": "Toate datele, inclusiv răspunsurile anterioare, rămân disponibile ca descărcare pe pagina de rezumat a sondajului.",
"caution_explanation_responses_are_safe": "Răspunsurile mai vechi și mai noi se amestecă, ceea ce poate duce la rezumate de date înșelătoare.", "caution_explanation_responses_are_safe": "Răspunsurile mai vechi și mai noi se amestecă, ceea ce poate duce la rezumate de date înșelătoare.",
"caution_recommendation": "Aceasta poate cauza inconsistențe de date în rezumatul sondajului. Vă recomandăm să duplicați sondajul în schimb.", "caution_recommendation": "Aceasta poate cauza inconsistențe de date în rezultatul sondajului. Vă recomandăm să duplicați sondajul în schimb.",
"caution_text": "Schimbările vor duce la inconsecvențe", "caution_text": "Schimbările vor duce la inconsecvențe",
"centered_modal_overlay_color": "Culoare suprapunere modală centralizată", "centered_modal_overlay_color": "Culoare suprapunere modală centralizată",
"change_anyway": "Schimbă oricum", "change_anyway": "Schimbă oricum",
@@ -1313,7 +1315,7 @@
"conditional_logic": "Logică condițională", "conditional_logic": "Logică condițională",
"confirm_default_language": "Confirmați limba implicită", "confirm_default_language": "Confirmați limba implicită",
"confirm_survey_changes": "Confirmă modificările sondajului", "confirm_survey_changes": "Confirmă modificările sondajului",
"contact_fields": "C<EFBFBD>mpuri de contact", "contact_fields": "Câmpuri de contact",
"contains": "Conține", "contains": "Conține",
"continue_to_settings": "Continuă către Setări", "continue_to_settings": "Continuă către Setări",
"control_which_file_types_can_be_uploaded": "Controlează ce tipuri de fișiere pot fi încărcate.", "control_which_file_types_can_be_uploaded": "Controlează ce tipuri de fișiere pot fi încărcate.",
@@ -1340,7 +1342,7 @@
"does_not_include_all_of": "Nu include toate", "does_not_include_all_of": "Nu include toate",
"does_not_include_one_of": "Nu include una dintre", "does_not_include_one_of": "Nu include una dintre",
"does_not_start_with": "Nu începe cu", "does_not_start_with": "Nu începe cu",
"edit_recall": "Editează Amintirea", "edit_recall": "Editează Referințele",
"edit_translations": "Editează traducerile {lang}", "edit_translations": "Editează traducerile {lang}",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permite participanților să schimbe limba sondajului în orice moment în timpul sondajului.", "enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permite participanților să schimbe limba sondajului în orice moment în timpul sondajului.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Protecția împotriva spamului folosește reCAPTCHA v3 pentru a filtra răspunsurile de spam.", "enable_recaptcha_to_protect_your_survey_from_spam": "Protecția împotriva spamului folosește reCAPTCHA v3 pentru a filtra răspunsurile de spam.",
@@ -1361,19 +1363,19 @@
"field_name_eg_score_price": "Nume câmp, de exemplu, scor, preț", "field_name_eg_score_price": "Nume câmp, de exemplu, scor, preț",
"first_name": "Prenume", "first_name": "Prenume",
"five_points_recommended": "5 puncte (recomandat)", "five_points_recommended": "5 puncte (recomandat)",
"follow_ups": "Urmăriri", "follow_ups": "Follow-up",
"follow_ups_delete_modal_text": "Sigur doriți să ștergeți acest follow-up?", "follow_ups_delete_modal_text": "Sigur doriți să ștergeți acest follow-up?",
"follow_ups_delete_modal_title": "Ștergeți urmărirea?", "follow_ups_delete_modal_title": "Ștergeți follow-up-ul?",
"follow_ups_empty_description": "Trimite mesaje respondentilor, ție sau colegilor de echipă.", "follow_ups_empty_description": "Trimite mesaje respondentilor, ție sau colegilor de echipă.",
"follow_ups_empty_heading": "Trimitere automată de urmăriri", "follow_ups_empty_heading": "Trimitere automată de follow-up",
"follow_ups_ending_card_delete_modal_text": "Această cartă de sfârșit este folosită în urmăriri ulterioare. Ștergerea sa o va elimina din toate urmăriri ulterioare. Ești sigur că vrei să o ștergi?", "follow_ups_ending_card_delete_modal_text": "Această cartă de sfârșit este folosită în follow-up-uri ulterioare. Ștergerea sa o va elimina din toate follow-up-uri ulterioare. Ești sigur că vrei să o ștergi?",
"follow_ups_ending_card_delete_modal_title": "Șterge cardul de finalizare?", "follow_ups_ending_card_delete_modal_title": "Șterge cardul de finalizare?",
"follow_ups_hidden_field_error": "Câmpul ascuns este utilizat într-un follow-up. Vă rugăm să îl eliminați mai întâi din follow-up.", "follow_ups_hidden_field_error": "Câmpul ascuns este utilizat într-un follow-up. Vă rugăm să îl eliminați mai întâi din follow-up.",
"follow_ups_item_ending_tag": "Finalizare", "follow_ups_item_ending_tag": "Finalizare",
"follow_ups_item_issue_detected_tag": "Problemă detectată", "follow_ups_item_issue_detected_tag": "Problemă detectată",
"follow_ups_item_response_tag": "Orice răspuns", "follow_ups_item_response_tag": "Orice răspuns",
"follow_ups_item_send_email_tag": "Trimite email", "follow_ups_item_send_email_tag": "Trimite email",
"follow_ups_modal_action_attach_response_data_description": "Adăugați datele răspunsului la sondaj la urmărire", "follow_ups_modal_action_attach_response_data_description": "Adăugați datele răspunsului la sondaj la follow-up",
"follow_ups_modal_action_attach_response_data_label": "Atașează datele răspunsului", "follow_ups_modal_action_attach_response_data_label": "Atașează datele răspunsului",
"follow_ups_modal_action_body_label": "Corp", "follow_ups_modal_action_body_label": "Corp",
"follow_ups_modal_action_body_placeholder": "Corpul emailului", "follow_ups_modal_action_body_placeholder": "Corpul emailului",
@@ -1393,8 +1395,8 @@
"follow_ups_modal_create_heading": "Creați o nouă urmărire", "follow_ups_modal_create_heading": "Creați o nouă urmărire",
"follow_ups_modal_edit_heading": "Editează acest follow-up", "follow_ups_modal_edit_heading": "Editează acest follow-up",
"follow_ups_modal_edit_no_id": "Nu a fost furnizat un ID de urmărire al chestionarului, nu pot actualiza urmărirea chestionarului", "follow_ups_modal_edit_no_id": "Nu a fost furnizat un ID de urmărire al chestionarului, nu pot actualiza urmărirea chestionarului",
"follow_ups_modal_name_label": "Numele urmăririi", "follow_ups_modal_name_label": "Numele ",
"follow_ups_modal_name_placeholder": "Denumirea urmăririi tale", "follow_ups_modal_name_placeholder": "Denumirea follow-up-ului tău",
"follow_ups_modal_subheading": "Trimite mesaje respondentilor, ție sau colegilor de echipă", "follow_ups_modal_subheading": "Trimite mesaje respondentilor, ție sau colegilor de echipă",
"follow_ups_modal_trigger_description": "Când ar trebui să fie declanșat acest follow-up?", "follow_ups_modal_trigger_description": "Când ar trebui să fie declanșat acest follow-up?",
"follow_ups_modal_trigger_label": "Declanșator", "follow_ups_modal_trigger_label": "Declanșator",
@@ -1402,7 +1404,7 @@
"follow_ups_modal_trigger_type_ending_select": "Selectează finalurile:", "follow_ups_modal_trigger_type_ending_select": "Selectează finalurile:",
"follow_ups_modal_trigger_type_ending_warning": "Nu s-au găsit finalizări în sondaj!", "follow_ups_modal_trigger_type_ending_warning": "Nu s-au găsit finalizări în sondaj!",
"follow_ups_modal_trigger_type_response": "Respondent finalizează sondajul", "follow_ups_modal_trigger_type_response": "Respondent finalizează sondajul",
"follow_ups_new": "Urmărire nouă", "follow_ups_new": "Follow-up nou",
"follow_ups_upgrade_button_text": "Actualizați pentru a activa urmărările", "follow_ups_upgrade_button_text": "Actualizați pentru a activa urmărările",
"form_styling": "Stilizare formular", "form_styling": "Stilizare formular",
"formbricks_sdk_is_not_connected": "SDK Formbricks nu este conectat", "formbricks_sdk_is_not_connected": "SDK Formbricks nu este conectat",
@@ -1459,10 +1461,10 @@
"logic_error_warning_text": "Schimbarea tipului de întrebare va elimina condițiile de logică din această întrebare", "logic_error_warning_text": "Schimbarea tipului de întrebare va elimina condițiile de logică din această întrebare",
"long_answer": "Răspuns lung", "long_answer": "Răspuns lung",
"lower_label": "Etichetă inferioară", "lower_label": "Etichetă inferioară",
"manage_languages": "Gestionați Limbile", "manage_languages": "Gestionați limbile",
"max_file_size": "Dimensiune maximă fișier", "max_file_size": "Dimensiune maximă fișier",
"max_file_size_limit_is": "Limita dimensiunii maxime a fișierului este", "max_file_size_limit_is": "Limita dimensiunii maxime a fișierului este",
"multiply": "Înmulțire *", "multiply": "Multiplicare",
"needed_for_self_hosted_cal_com_instance": "Necesar pentru un exemplu autogăzduit Cal.com", "needed_for_self_hosted_cal_com_instance": "Necesar pentru un exemplu autogăzduit Cal.com",
"next_button_label": "Etichetă buton \"Următorul\"", "next_button_label": "Etichetă buton \"Următorul\"",
"next_question": "Întrebarea următoare", "next_question": "Întrebarea următoare",
@@ -1512,15 +1514,15 @@
"release_survey_on_date": "Eliberați sondajul la dată", "release_survey_on_date": "Eliberați sondajul la dată",
"remove_description": "Eliminați descrierea", "remove_description": "Eliminați descrierea",
"remove_translations": "Eliminați traducerile", "remove_translations": "Eliminați traducerile",
"require_answer": "Cere Răspuns", "require_answer": "Cere răspuns",
"required": "Obligatoriu", "required": "Obligatoriu",
"reset_to_theme_styles": "Resetare la stilurile temei", "reset_to_theme_styles": "Resetare la stilurile temei",
"reset_to_theme_styles_main_text": "Sigur doriți să resetați stilul la stilurile de temă? Acest lucru va elimina toate stilizările personalizate.", "reset_to_theme_styles_main_text": "Sigur doriți să resetați stilul la stilurile de temă? Acest lucru va elimina toate stilizările personalizate.",
"response_limit_can_t_be_set_to_0": "Limitul de răspunsuri nu poate fi setat la 0", "response_limit_can_t_be_set_to_0": "Limitul de răspunsuri nu poate fi setat la 0",
"response_limit_needs_to_exceed_number_of_received_responses": "Limita răspunsurilor trebuie să depășească numărul de răspunsuri primite ({responseCount}).", "response_limit_needs_to_exceed_number_of_received_responses": "Limita răspunsurilor trebuie să depășească numărul de răspunsuri primite ({responseCount}).",
"response_limits_redirections_and_more": "Limite de răspunsuri, redirecționări și altele.", "response_limits_redirections_and_more": "Limite de răspunsuri, redirecționări și altele.",
"response_options": "Opțiuni Răspuns", "response_options": "Opțiuni răspuns",
"roundness": "Rotunjirea", "roundness": "Rotunjire",
"row_used_in_logic_error": "Această linie este folosită în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.", "row_used_in_logic_error": "Această linie este folosită în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
"rows": "Rânduri", "rows": "Rânduri",
"save_and_close": "Salvează & Închide", "save_and_close": "Salvează & Închide",
@@ -1580,7 +1582,7 @@
"three_points": "3 puncte", "three_points": "3 puncte",
"times": "ori", "times": "ori",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Pentru a menține amplasarea consecventă pentru toate sondajele, puteți", "to_keep_the_placement_over_all_surveys_consistent_you_can": "Pentru a menține amplasarea consecventă pentru toate sondajele, puteți",
"trigger_survey_when_one_of_the_actions_is_fired": "Declanșați sondajul atunci când una dintre acțiuni este declanșată...", "trigger_survey_when_one_of_the_actions_is_fired": "Declanșați sondajul atunci când una dintre acțiuni este realizată...",
"try_lollipop_or_mountain": "Încercați „lollipop” sau „mountain”...", "try_lollipop_or_mountain": "Încercați „lollipop” sau „mountain”...",
"type_field_id": "ID câmp tip", "type_field_id": "ID câmp tip",
"unlock_targeting_description": "Vizează grupuri specifice de utilizatori pe baza atributelor sau a informațiilor despre dispozitiv", "unlock_targeting_description": "Vizează grupuri specifice de utilizatori pe baza atributelor sau a informațiilor despre dispozitiv",
@@ -1619,7 +1621,7 @@
"complete_responses": "Răspunsuri complete", "complete_responses": "Răspunsuri complete",
"partial_responses": "Răspunsuri parțiale" "partial_responses": "Răspunsuri parțiale"
}, },
"new_survey": "Chestionar Nou", "new_survey": "Chestionar nou",
"no_surveys_created_yet": "Nu au fost create încă chestionare", "no_surveys_created_yet": "Nu au fost create încă chestionare",
"open_options": "Opțiuni deschise", "open_options": "Opțiuni deschise",
"preview_survey_in_a_new_tab": "Previzualizare chestionar în alt tab", "preview_survey_in_a_new_tab": "Previzualizare chestionar în alt tab",
@@ -1732,7 +1734,7 @@
"send_email": { "send_email": {
"copy_embed_code": "Copiază codul de inserare", "copy_embed_code": "Copiază codul de inserare",
"description": "Inserați sondajul dvs. într-un e-mail pentru a obține răspunsuri de la audiența dvs.", "description": "Inserați sondajul dvs. într-un e-mail pentru a obține răspunsuri de la audiența dvs.",
"email_preview_tab": "Previzualizare Email", "email_preview_tab": "Previzualizare email",
"email_sent": "Email trimis!", "email_sent": "Email trimis!",
"email_subject_label": "Subiect", "email_subject_label": "Subiect",
"email_to_label": "Către", "email_to_label": "Către",
@@ -1776,7 +1778,7 @@
"filter_updated_successfully": "Filtru actualizat cu succes", "filter_updated_successfully": "Filtru actualizat cu succes",
"filtered_responses_csv": "Răspunsuri filtrate (CSV)", "filtered_responses_csv": "Răspunsuri filtrate (CSV)",
"filtered_responses_excel": "Răspunsuri filtrate (Excel)", "filtered_responses_excel": "Răspunsuri filtrate (Excel)",
"go_to_setup_checklist": "Mergi la Lista de Verificare a Configurării \uD83D\uDC49", "go_to_setup_checklist": "Mergi la lista de verificare a configurării \uD83D\uDC49",
"impressions": "Impresii", "impressions": "Impresii",
"impressions_tooltip": "Număr de ori când sondajul a fost vizualizat.", "impressions_tooltip": "Număr de ori când sondajul a fost vizualizat.",
"in_app": { "in_app": {
@@ -1943,7 +1945,7 @@
"intro": { "intro": {
"get_started": "Începeți", "get_started": "Începeți",
"made_with_love_in_kiel": "Creat cu \uD83E\uDD0D în Germania", "made_with_love_in_kiel": "Creat cu \uD83E\uDD0D în Germania",
"paragraph_1": "Formbricks este o Suită de Management al Experiențelor construită pe baza <b>platformei de sondaje open source care crește cel mai rapid</b> din lume.", "paragraph_1": "Formbricks este o suită de management al experiențelor construită pe baza <b>platformei de sondaje open source care crește cel mai rapid</b> din lume.",
"paragraph_2": "Rulați sondaje direcționate pe site-uri web, în aplicații sau oriunde online. Adunați informații valoroase pentru a <b>crea experiențe irezistibile</b> pentru clienți, utilizatori și angajați.", "paragraph_2": "Rulați sondaje direcționate pe site-uri web, în aplicații sau oriunde online. Adunați informații valoroase pentru a <b>crea experiențe irezistibile</b> pentru clienți, utilizatori și angajați.",
"paragraph_3": "Suntem angajați la cel mai înalt grad de confidențialitate a datelor. Găzduirea proprie vă oferă <b>control deplin asupra datelor dumneavoastră</b>.", "paragraph_3": "Suntem angajați la cel mai înalt grad de confidențialitate a datelor. Găzduirea proprie vă oferă <b>control deplin asupra datelor dumneavoastră</b>.",
"welcome_to_formbricks": "Bine ai venit la Formbricks!" "welcome_to_formbricks": "Bine ai venit la Formbricks!"
@@ -2043,7 +2045,7 @@
"career_development_survey_question_4_headline": "Sunt mulțumit de investiția pe care organizația mea o face în formare și educație.", "career_development_survey_question_4_headline": "Sunt mulțumit de investiția pe care organizația mea o face în formare și educație.",
"career_development_survey_question_4_lower_label": "Dezacord puternic", "career_development_survey_question_4_lower_label": "Dezacord puternic",
"career_development_survey_question_4_upper_label": "De acord cu tărie", "career_development_survey_question_4_upper_label": "De acord cu tărie",
"career_development_survey_question_5_choice_1": "Dezvoltare de Produs", "career_development_survey_question_5_choice_1": "Dezvoltare de produs",
"career_development_survey_question_5_choice_2": "Marketing", "career_development_survey_question_5_choice_2": "Marketing",
"career_development_survey_question_5_choice_3": "Relații Publice", "career_development_survey_question_5_choice_3": "Relații Publice",
"career_development_survey_question_5_choice_4": "Contabilitate", "career_development_survey_question_5_choice_4": "Contabilitate",
@@ -2113,7 +2115,7 @@
"collect_feedback_question_5_headline": "Mai dorești să împărtășești altceva cu echipa noastră?", "collect_feedback_question_5_headline": "Mai dorești să împărtășești altceva cu echipa noastră?",
"collect_feedback_question_5_placeholder": "Tastează răspunsul aici...", "collect_feedback_question_5_placeholder": "Tastează răspunsul aici...",
"collect_feedback_question_6_choice_1": "Google", "collect_feedback_question_6_choice_1": "Google",
"collect_feedback_question_6_choice_2": "Rețele Sociale", "collect_feedback_question_6_choice_2": "Rețele sociale",
"collect_feedback_question_6_choice_3": "Prieteni", "collect_feedback_question_6_choice_3": "Prieteni",
"collect_feedback_question_6_choice_4": "Podcast", "collect_feedback_question_6_choice_4": "Podcast",
"collect_feedback_question_6_choice_5": "Altele", "collect_feedback_question_6_choice_5": "Altele",
@@ -2242,7 +2244,7 @@
"earned_advocacy_score_question_5_headline": "Ce te-a făcut să îi descurajezi?", "earned_advocacy_score_question_5_headline": "Ce te-a făcut să îi descurajezi?",
"earned_advocacy_score_question_5_placeholder": "Tastează răspunsul aici...", "earned_advocacy_score_question_5_placeholder": "Tastează răspunsul aici...",
"employee_satisfaction_description": "Evaluează satisfacția angajaților și identifică domeniile de îmbunătățire.", "employee_satisfaction_description": "Evaluează satisfacția angajaților și identifică domeniile de îmbunătățire.",
"employee_satisfaction_name": "Satisfacție a Angajatului", "employee_satisfaction_name": "Satisfacție a angajatului",
"employee_satisfaction_question_1_headline": "Cât de satisfăcut sunteți de rolul dvs. actual?", "employee_satisfaction_question_1_headline": "Cât de satisfăcut sunteți de rolul dvs. actual?",
"employee_satisfaction_question_1_lower_label": "Nesatisfăcut", "employee_satisfaction_question_1_lower_label": "Nesatisfăcut",
"employee_satisfaction_question_1_upper_label": "Foarte mulțumit", "employee_satisfaction_question_1_upper_label": "Foarte mulțumit",
@@ -2266,7 +2268,7 @@
"employee_satisfaction_question_7_choice_5": "Deloc probabil", "employee_satisfaction_question_7_choice_5": "Deloc probabil",
"employee_satisfaction_question_7_headline": "Cât de probabil este să recomandați compania noastră unui prieten?", "employee_satisfaction_question_7_headline": "Cât de probabil este să recomandați compania noastră unui prieten?",
"employee_well_being_description": "Evaluează bunăstarea angajatului prin echilibrul între muncă și viață, volumul de muncă și mediul de lucru.", "employee_well_being_description": "Evaluează bunăstarea angajatului prin echilibrul între muncă și viață, volumul de muncă și mediul de lucru.",
"employee_well_being_name": "Bunăstarea Angajatului", "employee_well_being_name": "Bunăstarea angajatului",
"employee_well_being_question_1_headline": "Simt că am un echilibru bun între viața mea profesională și cea personală.", "employee_well_being_question_1_headline": "Simt că am un echilibru bun între viața mea profesională și cea personală.",
"employee_well_being_question_1_lower_label": "Echilibru foarte slab", "employee_well_being_question_1_lower_label": "Echilibru foarte slab",
"employee_well_being_question_1_upper_label": "Echilibru excelent", "employee_well_being_question_1_upper_label": "Echilibru excelent",
@@ -2328,7 +2330,7 @@
"fake_door_follow_up_question_2_choice_4": "Aspectul 4", "fake_door_follow_up_question_2_choice_4": "Aspectul 4",
"fake_door_follow_up_question_2_headline": "Ce ar trebui să includem cu siguranță în construirea acestuia?", "fake_door_follow_up_question_2_headline": "Ce ar trebui să includem cu siguranță în construirea acestuia?",
"feature_chaser_description": "Urmăriți utilizatorii care tocmai au folosit o funcție specifică.", "feature_chaser_description": "Urmăriți utilizatorii care tocmai au folosit o funcție specifică.",
"feature_chaser_name": "Urmăritor de Funcționalități", "feature_chaser_name": "Urmăritor de funcționalități",
"feature_chaser_question_1_headline": "Cât de importantă este [ADD FEATURE] pentru tine?", "feature_chaser_question_1_headline": "Cât de importantă este [ADD FEATURE] pentru tine?",
"feature_chaser_question_1_lower_label": "Neimportant", "feature_chaser_question_1_lower_label": "Neimportant",
"feature_chaser_question_1_upper_label": "Foarte important", "feature_chaser_question_1_upper_label": "Foarte important",
@@ -2369,7 +2371,7 @@
"identify_customer_goals_description": "Înțelegeți mai bine dacă mesajele voastre creează așteptările corecte privind valoarea pe care o oferă produsul vostru.", "identify_customer_goals_description": "Înțelegeți mai bine dacă mesajele voastre creează așteptările corecte privind valoarea pe care o oferă produsul vostru.",
"identify_customer_goals_name": "Identifică Obiectivele Clienților", "identify_customer_goals_name": "Identifică Obiectivele Clienților",
"identify_sign_up_barriers_description": "Oferiți o reducere pentru a obține informații despre barierele de înscriere.", "identify_sign_up_barriers_description": "Oferiți o reducere pentru a obține informații despre barierele de înscriere.",
"identify_sign_up_barriers_name": "Identificați Barierele de Înscriere", "identify_sign_up_barriers_name": "Identificați barierele de înscriere",
"identify_sign_up_barriers_question_1_button_label": "Obține reducere de 10%", "identify_sign_up_barriers_question_1_button_label": "Obține reducere de 10%",
"identify_sign_up_barriers_question_1_dismiss_button_label": "Nu, mulţumesc", "identify_sign_up_barriers_question_1_dismiss_button_label": "Nu, mulţumesc",
"identify_sign_up_barriers_question_1_headline": "Răspunde acestui scurt sondaj, primește 10% reducere!", "identify_sign_up_barriers_question_1_headline": "Răspunde acestui scurt sondaj, primește 10% reducere!",
@@ -2405,7 +2407,7 @@
"identify_upsell_opportunities_question_1_choice_4": "5+ ore", "identify_upsell_opportunities_question_1_choice_4": "5+ ore",
"identify_upsell_opportunities_question_1_headline": "Câte ore economisește echipa dumneavoastră pe săptămână folosind $[projectName]?", "identify_upsell_opportunities_question_1_headline": "Câte ore economisește echipa dumneavoastră pe săptămână folosind $[projectName]?",
"improve_activation_rate_description": "Identifică punctele slabe în fluxul de onboarding pentru a crește activarea utilizatorilor.", "improve_activation_rate_description": "Identifică punctele slabe în fluxul de onboarding pentru a crește activarea utilizatorilor.",
"improve_activation_rate_name": "Îmbunătățește Rata de Activare", "improve_activation_rate_name": "Îmbunătățește rata de activare",
"improve_activation_rate_question_1_choice_1": "Nu părea util pentru mine", "improve_activation_rate_question_1_choice_1": "Nu părea util pentru mine",
"improve_activation_rate_question_1_choice_2": "Dificil de configurat sau utilizat", "improve_activation_rate_question_1_choice_2": "Dificil de configurat sau utilizat",
"improve_activation_rate_question_1_choice_3": "Lipsit de funcții/funcționalități", "improve_activation_rate_question_1_choice_3": "Lipsit de funcții/funcționalități",
@@ -2427,12 +2429,12 @@
"improve_newsletter_content_name": "Îmbunătățește Conținutul Newsletterului", "improve_newsletter_content_name": "Îmbunătățește Conținutul Newsletterului",
"improve_newsletter_content_question_1_headline": "Cum ați evalua newsletterul din această săptămână?", "improve_newsletter_content_question_1_headline": "Cum ați evalua newsletterul din această săptămână?",
"improve_newsletter_content_question_1_lower_label": "Însă", "improve_newsletter_content_question_1_lower_label": "Însă",
"improve_newsletter_content_question_1_upper_label": "Groza", "improve_newsletter_content_question_1_upper_label": "Grozav",
"improve_newsletter_content_question_2_headline": "Ce ar fi făcut ca newsletter-ul din această săptămână să fie mai util?", "improve_newsletter_content_question_2_headline": "Ce ar fi făcut ca newsletter-ul din această săptămână să fie mai util?",
"improve_newsletter_content_question_2_placeholder": "Tastează răspunsul aici...", "improve_newsletter_content_question_2_placeholder": "Tastează răspunsul aici...",
"improve_newsletter_content_question_3_button_label": "Bucuros să ajut!", "improve_newsletter_content_question_3_button_label": "Bucuros să ajut!",
"improve_newsletter_content_question_3_dismiss_button_label": "Găsește-ți proprii prieteni", "improve_newsletter_content_question_3_dismiss_button_label": "Găsește-ți proprii prieteni",
"improve_newsletter_content_question_3_headline": "Mulțumim! ❤️ Răspândește iubirea către UN prieten.", "improve_newsletter_content_question_3_headline": "Mulțumim! ❤️ Răspândește iubirea către un prieten.",
"improve_newsletter_content_question_3_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Cine gândește ca tine? Ne-ai face o mare favoare dacă ai împărtăși episodul acestei săptămâni cu prietenul tău de creier!</span></p>", "improve_newsletter_content_question_3_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Cine gândește ca tine? Ne-ai face o mare favoare dacă ai împărtăși episodul acestei săptămâni cu prietenul tău de creier!</span></p>",
"improve_trial_conversion_description": "Află de ce oamenii au încetat perioada de încercare. Aceste informații te ajută să îți îmbunătățești procesul de achiziție.", "improve_trial_conversion_description": "Află de ce oamenii au încetat perioada de încercare. Aceste informații te ajută să îți îmbunătățești procesul de achiziție.",
"improve_trial_conversion_name": "Îmbunătățește Conversia În Proba", "improve_trial_conversion_name": "Îmbunătățește Conversia În Proba",
@@ -2464,7 +2466,7 @@
"integration_setup_survey_question_3_headline": "Ce alte instrumente ați dori să utilizați cu $[projectName]?", "integration_setup_survey_question_3_headline": "Ce alte instrumente ați dori să utilizați cu $[projectName]?",
"integration_setup_survey_question_3_subheader": "Continuăm să dezvoltăm integrări, a ta poate fi următoarea:", "integration_setup_survey_question_3_subheader": "Continuăm să dezvoltăm integrări, a ta poate fi următoarea:",
"interview_prompt_description": "Invită un subset specific de utilizatori să programeze un interviu cu echipa ta de produs.", "interview_prompt_description": "Invită un subset specific de utilizatori să programeze un interviu cu echipa ta de produs.",
"interview_prompt_name": "Întrebare Interviu", "interview_prompt_name": "Întrebare interviu",
"interview_prompt_question_1_button_label": "Rezervă intervalul", "interview_prompt_question_1_button_label": "Rezervă intervalul",
"interview_prompt_question_1_headline": "Ai 15 minute să discuți cu noi? \uD83D\uDE4F", "interview_prompt_question_1_headline": "Ai 15 minute să discuți cu noi? \uD83D\uDE4F",
"interview_prompt_question_1_html": "Ești unul dintre utilizatorii noștri frecvenți. Ne-ar plăcea să te intervievăm pe scurt!", "interview_prompt_question_1_html": "Ești unul dintre utilizatorii noștri frecvenți. Ne-ar plăcea să te intervievăm pe scurt!",
@@ -2503,16 +2505,16 @@
"long_term_retention_check_in_question_9_lower_label": "Nemulțumit", "long_term_retention_check_in_question_9_lower_label": "Nemulțumit",
"long_term_retention_check_in_question_9_upper_label": "Foarte mulțumit", "long_term_retention_check_in_question_9_upper_label": "Foarte mulțumit",
"market_attribution_description": "Aflați cum au auzit utilizatorii pentru prima dată despre produsul dumneavoastră.", "market_attribution_description": "Aflați cum au auzit utilizatorii pentru prima dată despre produsul dumneavoastră.",
"market_attribution_name": "Atribuirea Marketingului", "market_attribution_name": "Atribuirea marketingului",
"market_attribution_question_1_choice_1": "Recomandare", "market_attribution_question_1_choice_1": "Recomandare",
"market_attribution_question_1_choice_2": "Rețele Sociale", "market_attribution_question_1_choice_2": "Rețele sociale",
"market_attribution_question_1_choice_3": "Reclame", "market_attribution_question_1_choice_3": "Reclame",
"market_attribution_question_1_choice_4": "Căutare Google", "market_attribution_question_1_choice_4": "Căutare Google",
"market_attribution_question_1_choice_5": "Într-un Podcast", "market_attribution_question_1_choice_5": "Într-un podcast",
"market_attribution_question_1_headline": "Cum ați aflat pentru prima dată despre noi?", "market_attribution_question_1_headline": "Cum ați aflat pentru prima dată despre noi?",
"market_attribution_question_1_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:", "market_attribution_question_1_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:",
"market_site_clarity_description": "Identificați utilizatorii care părăsesc site-ul dvs. de marketing. Îmbunătățiți mesajele dvs.", "market_site_clarity_description": "Identificați utilizatorii care părăsesc site-ul dvs. de marketing. Îmbunătățiți mesajele dvs.",
"market_site_clarity_name": "Claritate Site de Marketing", "market_site_clarity_name": "Claritate site de marketing",
"market_site_clarity_question_1_choice_1": "Da, complet", "market_site_clarity_question_1_choice_1": "Da, complet",
"market_site_clarity_question_1_choice_2": "Un fel de...", "market_site_clarity_question_1_choice_2": "Un fel de...",
"market_site_clarity_question_1_choice_3": "Nu, deloc", "market_site_clarity_question_1_choice_3": "Nu, deloc",
@@ -2590,17 +2592,17 @@
"onboarding_segmentation_question_2_headline": "Care este dimensiunea companiei dumneavoastră?", "onboarding_segmentation_question_2_headline": "Care este dimensiunea companiei dumneavoastră?",
"onboarding_segmentation_question_2_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:", "onboarding_segmentation_question_2_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:",
"onboarding_segmentation_question_3_choice_1": "Recomandare", "onboarding_segmentation_question_3_choice_1": "Recomandare",
"onboarding_segmentation_question_3_choice_2": "Rețele Sociale", "onboarding_segmentation_question_3_choice_2": "Rețele sociale",
"onboarding_segmentation_question_3_choice_3": "Reclame", "onboarding_segmentation_question_3_choice_3": "Reclame",
"onboarding_segmentation_question_3_choice_4": "Căutare Google", "onboarding_segmentation_question_3_choice_4": "Căutare Google",
"onboarding_segmentation_question_3_choice_5": "Într-un Podcast", "onboarding_segmentation_question_3_choice_5": "Într-un Podcast",
"onboarding_segmentation_question_3_headline": "Cum ai aflat pentru prima dată despre noi?", "onboarding_segmentation_question_3_headline": "Cum ai aflat pentru prima dată despre noi?",
"onboarding_segmentation_question_3_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:", "onboarding_segmentation_question_3_subheader": "Vă rugăm să selectați una dintre următoarele opțiuni:",
"picture_selection": "Selecție Poze", "picture_selection": "Selecție poze",
"picture_selection_description": "Cereți respondenților să aleagă una sau mai multe imagini", "picture_selection_description": "Cereți respondenților să aleagă una sau mai multe imagini",
"preview_survey_ending_card_description": "Vă rugăm să continuați onboarding-ul.", "preview_survey_ending_card_description": "Vă rugăm să continuați onboarding-ul.",
"preview_survey_ending_card_headline": "Ai reușit!", "preview_survey_ending_card_headline": "Ai reușit!",
"preview_survey_name": "Previzualizare Chestionar", "preview_survey_name": "Previzualizare chestionar",
"preview_survey_question_1_headline": "Cum ai evalua {projectName}?", "preview_survey_question_1_headline": "Cum ai evalua {projectName}?",
"preview_survey_question_1_lower_label": "Nu este bine", "preview_survey_question_1_lower_label": "Nu este bine",
"preview_survey_question_1_subheader": "Aceasta este o previzualizare a chestionarului.", "preview_survey_question_1_subheader": "Aceasta este o previzualizare a chestionarului.",
@@ -2612,7 +2614,7 @@
"preview_survey_welcome_card_headline": "Bun venit!", "preview_survey_welcome_card_headline": "Bun venit!",
"preview_survey_welcome_card_html": "Mulțumesc pentru feedback-ul dvs - să începem!", "preview_survey_welcome_card_html": "Mulțumesc pentru feedback-ul dvs - să începem!",
"prioritize_features_description": "Identificați caracteristicile de care utilizatorii dumneavoastră au cel mai mult și cel mai puțin nevoie.", "prioritize_features_description": "Identificați caracteristicile de care utilizatorii dumneavoastră au cel mai mult și cel mai puțin nevoie.",
"prioritize_features_name": "Prioritizați Caracteristicile", "prioritize_features_name": "Prioritizați caracteristicile",
"prioritize_features_question_1_choice_1": "Caracteristica 1", "prioritize_features_question_1_choice_1": "Caracteristica 1",
"prioritize_features_question_1_choice_2": "Caracteristica 2", "prioritize_features_question_1_choice_2": "Caracteristica 2",
"prioritize_features_question_1_choice_3": "Caracteristica 3", "prioritize_features_question_1_choice_3": "Caracteristica 3",
@@ -2656,7 +2658,7 @@
"product_market_fit_superhuman_question_6_headline": "Cum putem îmbunătăți $[projectName] pentru dumneavoastră?", "product_market_fit_superhuman_question_6_headline": "Cum putem îmbunătăți $[projectName] pentru dumneavoastră?",
"product_market_fit_superhuman_question_6_subheader": "Vă rugăm să fiți cât mai specific posibil.", "product_market_fit_superhuman_question_6_subheader": "Vă rugăm să fiți cât mai specific posibil.",
"professional_development_growth_survey_description": "Evaluează satisfacția angajaților cu privire la oportunitățile de dezvoltare și creștere profesională.", "professional_development_growth_survey_description": "Evaluează satisfacția angajaților cu privire la oportunitățile de dezvoltare și creștere profesională.",
"professional_development_growth_survey_name": "Sondaj de Creștere și Dezvoltare Profesională", "professional_development_growth_survey_name": "Sondaj de creștere și dezvoltare profesională",
"professional_development_growth_survey_question_1_headline": "Simt că am oportunități să cresc și să-mi dezvolt abilitățile la muncă.", "professional_development_growth_survey_question_1_headline": "Simt că am oportunități să cresc și să-mi dezvolt abilitățile la muncă.",
"professional_development_growth_survey_question_1_lower_label": "Nicio oportunitate de creștere", "professional_development_growth_survey_question_1_lower_label": "Nicio oportunitate de creștere",
"professional_development_growth_survey_question_1_upper_label": "Multe oportunități de creștere", "professional_development_growth_survey_question_1_upper_label": "Multe oportunități de creștere",

View File

@@ -125,6 +125,7 @@
"add_filter": "新增篩選器", "add_filter": "新增篩選器",
"add_logo": "新增標誌", "add_logo": "新增標誌",
"add_member": "新增成員", "add_member": "新增成員",
"add_new_project": "新增 新專案",
"add_project": "新增專案", "add_project": "新增專案",
"add_to_team": "新增至團隊", "add_to_team": "新增至團隊",
"all": "全部", "all": "全部",
@@ -149,6 +150,9 @@
"cancel": "取消", "cancel": "取消",
"centered_modal": "置中彈窗", "centered_modal": "置中彈窗",
"choices": "選項", "choices": "選項",
"choose_environment": "選擇環境",
"choose_organization": "選擇 組織",
"choose_project": "選擇 專案",
"clear_all": "全部清除", "clear_all": "全部清除",
"clear_filters": "清除篩選器", "clear_filters": "清除篩選器",
"clear_selection": "清除選取", "clear_selection": "清除選取",
@@ -177,7 +181,6 @@
"created_at": "建立時間", "created_at": "建立時間",
"created_by": "建立者", "created_by": "建立者",
"customer_success": "客戶成功", "customer_success": "客戶成功",
"danger_zone": "危險區域",
"dark_overlay": "深色覆蓋", "dark_overlay": "深色覆蓋",
"date": "日期", "date": "日期",
"default": "預設", "default": "預設",
@@ -201,6 +204,10 @@
"environment_not_found": "找不到環境", "environment_not_found": "找不到環境",
"environment_notice": "您目前在 '{'environment'}' 環境中。", "environment_notice": "您目前在 '{'environment'}' 環境中。",
"error": "錯誤", "error": "錯誤",
"error_component_description": "此資源不存在或您沒有存取權限。",
"error_component_title": "載入資源錯誤",
"error_rate_limit_description": "已達 到最大 請求 次數。請 稍後 再試。",
"error_rate_limit_title": "限流超過",
"expand_rows": "展開列", "expand_rows": "展開列",
"finish": "完成", "finish": "完成",
"follow_these": "按照這些步驟", "follow_these": "按照這些步驟",
@@ -232,11 +239,9 @@
"label": "標籤", "label": "標籤",
"language": "語言", "language": "語言",
"learn_more": "瞭解更多", "learn_more": "瞭解更多",
"license": "授權",
"light_overlay": "淺色覆蓋", "light_overlay": "淺色覆蓋",
"limits_reached": "已達上限", "limits_reached": "已達上限",
"link": "連結", "link": "連結",
"link_and_email": "連結與電子郵件",
"link_survey": "連結問卷", "link_survey": "連結問卷",
"link_surveys": "連結問卷", "link_surveys": "連結問卷",
"load_more": "載入更多", "load_more": "載入更多",
@@ -283,6 +288,7 @@
"organization": "組織", "organization": "組織",
"organization_id": "組織 ID", "organization_id": "組織 ID",
"organization_not_found": "找不到組織", "organization_not_found": "找不到組織",
"organization_settings": "組織設定",
"organization_teams_not_found": "找不到組織團隊", "organization_teams_not_found": "找不到組織團隊",
"other": "其他", "other": "其他",
"others": "其他", "others": "其他",
@@ -378,7 +384,6 @@
"survey_scheduled": "問卷已排程。", "survey_scheduled": "問卷已排程。",
"survey_type": "問卷類型", "survey_type": "問卷類型",
"surveys": "問卷", "surveys": "問卷",
"switch_organization": "切換組織",
"switch_to": "切換至 '{'environment'}'", "switch_to": "切換至 '{'environment'}'",
"table_items_deleted_successfully": "'{'type'}' 已成功刪除", "table_items_deleted_successfully": "'{'type'}' 已成功刪除",
"table_settings": "表格設定", "table_settings": "表格設定",
@@ -576,8 +581,6 @@
"contacts_table_refresh": "重新整理聯絡人", "contacts_table_refresh": "重新整理聯絡人",
"contacts_table_refresh_success": "聯絡人已成功重新整理", "contacts_table_refresh_success": "聯絡人已成功重新整理",
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。", "delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
"first_name": "名字",
"last_name": "姓氏",
"no_responses_found": "找不到回應", "no_responses_found": "找不到回應",
"not_provided": "未提供", "not_provided": "未提供",
"search_contact": "搜尋聯絡人", "search_contact": "搜尋聯絡人",
@@ -989,7 +992,6 @@
"free": "免費", "free": "免費",
"free_description": "無限問卷、團隊成員等。", "free_description": "無限問卷、團隊成員等。",
"get_2_months_free": "免費獲得 2 個月", "get_2_months_free": "免費獲得 2 個月",
"get_in_touch": "取得聯繫",
"hosted_in_frankfurt": "託管在 Frankfurt", "hosted_in_frankfurt": "託管在 Frankfurt",
"ios_android_sdks": "iOS 和 Android SDK 用於行動問卷", "ios_android_sdks": "iOS 和 Android SDK 用於行動問卷",
"link_surveys": "連結問卷(可分享)", "link_surveys": "連結問卷(可分享)",

View File

@@ -1,5 +1,5 @@
import { environmentId, fileUploadQuestion, openTextQuestion, responseData } from "./__mocks__/utils.mock"; 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 { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { okVoid } from "@formbricks/types/error-handlers"; 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(), deleteFile: vi.fn(),
})); }));
@@ -21,7 +21,7 @@ describe("findAndDeleteUploadedFilesInResponse", () => {
}); });
test("delete files for file upload questions and return okVoid", async () => { 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]); const result = await findAndDeleteUploadedFilesInResponse(responseData, [fileUploadQuestion]);
@@ -56,7 +56,7 @@ describe("findAndDeleteUploadedFilesInResponse", () => {
}); });
test("process multiple file URLs", async () => { 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]); const result = await findAndDeleteUploadedFilesInResponse(responseData, [fileUploadQuestion]);

View File

@@ -1,5 +1,5 @@
import { deleteFile } from "@/lib/storage/service";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { deleteFile } from "@/modules/storage/service";
import { Response, Survey } from "@prisma/client"; import { Response, Survey } from "@prisma/client";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { Result, okVoid } from "@formbricks/types/error-handlers"; import { Result, okVoid } from "@formbricks/types/error-handlers";

View File

@@ -1,4 +1,3 @@
import { validateFileUploads } from "@/lib/fileValidation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question"; import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { responses } from "@/modules/api/v2/lib/response"; 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 { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { z } from "zod"; import { z } from "zod";
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses"; import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";

View File

@@ -1,4 +1,3 @@
import { validateFileUploads } from "@/lib/fileValidation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question"; import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { responses } from "@/modules/api/v2/lib/response"; 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 { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { Response } from "@prisma/client"; import { Response } from "@prisma/client";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { createResponse, getResponses } from "./lib/response"; import { createResponse, getResponses } from "./lib/response";

View File

@@ -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 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="mx-auto w-full max-w-sm rounded-xl bg-white p-8 shadow-xl lg:w-96">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<Link <Link target="_blank" href="https://formbricks.com?utm_source=ce" rel="noopener noreferrer">
target="_blank" <Logo className="mx-auto w-3/4" />
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> </Link>
</div> </div>
{children} {children}

View File

@@ -132,6 +132,8 @@ describe("rateLimitConfigs", () => {
{ config: rateLimitConfigs.api.client, identifier: "client-api-key" }, { config: rateLimitConfigs.api.client, identifier: "client-api-key" },
{ config: rateLimitConfigs.api.syncUserIdentification, identifier: "sync-user-id" }, { config: rateLimitConfigs.api.syncUserIdentification, identifier: "sync-user-id" },
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" }, { 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) { for (const { config, identifier } of testCases) {
@@ -175,5 +177,23 @@ describe("rateLimitConfigs", () => {
expect(exceededResult.data.allowed).toBe(false); 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");
});
}); });
}); });

View File

@@ -29,4 +29,9 @@ export const rateLimitConfigs = {
namespace: "action:send-link-survey-email", namespace: "action:send-link-survey-email",
}, // 10 per hour }, // 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
},
}; };

View File

@@ -255,11 +255,11 @@ export const UploadContactsCSVButton = ({
const headers = Object.keys(exampleData[0]); const headers = Object.keys(exampleData[0]);
const csvRows = [headers.join(","), ...exampleData.map((row) => headers.map((h) => row[h]).join(","))]; 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 csvString = csvRows.join("\n");
const encodedUri = encodeURI(csvContent); const csvContent = "data:text/csv;charset=utf-8," + encodeURIComponent(csvString);
const link = document.createElement("a"); const link = document.createElement("a");
link.setAttribute("href", encodedUri); link.setAttribute("href", csvContent);
link.setAttribute("download", "example.csv"); link.setAttribute("download", "example.csv");
document.body.appendChild(link); // Required for Firefox document.body.appendChild(link); // Required for Firefox
link.click(); link.click();

View File

@@ -1,9 +1,9 @@
import { handleFileUpload } from "@/app/lib/fileUpload";
import { import {
removeOrganizationEmailLogoUrlAction, removeOrganizationEmailLogoUrlAction,
sendTestEmailAction, sendTestEmailAction,
updateOrganizationEmailLogoUrlAction, updateOrganizationEmailLogoUrlAction,
} from "@/modules/ee/whitelabel/email-customization/actions"; } from "@/modules/ee/whitelabel/email-customization/actions";
import { handleFileUpload } from "@/modules/storage/file-upload";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
@@ -18,7 +18,7 @@ vi.mock("@/modules/ee/whitelabel/email-customization/actions", () => ({
updateOrganizationEmailLogoUrlAction: vi.fn(), updateOrganizationEmailLogoUrlAction: vi.fn(),
})); }));
vi.mock("@/app/lib/fileUpload", () => ({ vi.mock("@/modules/storage/file-upload", () => ({
handleFileUpload: vi.fn(), handleFileUpload: vi.fn(),
})); }));

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { handleFileUpload } from "@/app/lib/fileUpload";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { import {
@@ -9,6 +8,7 @@ import {
sendTestEmailAction, sendTestEmailAction,
updateOrganizationEmailLogoUrlAction, updateOrganizationEmailLogoUrlAction,
} from "@/modules/ee/whitelabel/email-customization/actions"; } from "@/modules/ee/whitelabel/email-customization/actions";
import { handleFileUpload } from "@/modules/storage/file-upload";
import { Alert, AlertDescription } from "@/modules/ui/components/alert"; import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Uploader } from "@/modules/ui/components/file-input/components/uploader"; import { Uploader } from "@/modules/ui/components/file-input/components/uploader";
@@ -20,8 +20,8 @@ import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import React, { useRef, useState } from "react"; import React, { useRef, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { TAllowedFileExtension } from "@formbricks/types/common";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TAllowedFileExtension } from "@formbricks/types/storage";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
const allowedFileExtensions: TAllowedFileExtension[] = ["jpeg", "png", "jpg", "webp"]; const allowedFileExtensions: TAllowedFileExtension[] = ["jpeg", "png", "jpg", "webp"];

View File

@@ -15,7 +15,7 @@ vi.mock("@react-email/components", () => ({
})); }));
// Mock dependencies // Mock dependencies
vi.mock("@/lib/storage/utils", () => ({ vi.mock("@/modules/storage/utils", () => ({
getOriginalFileNameFromUrl: (url: string) => { getOriginalFileNameFromUrl: (url: string) => {
// Extract filename from the URL for testing purposes // Extract filename from the URL for testing purposes
const parts = url.split("/"); 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 () => { test("renders clickable file upload links with file icons and truncated file names when overrideFileUploadResponse is false", async () => {
// Arrange // Arrange
const fileUrls = [ const fileUrls = [
"https://example.com/uploads/file1.pdf", "https://example.com/files/file1.pdf",
"https://example.com/uploads/very-long-filename-that-should-be-truncated.docx", "https://example.com/files/very-long-filename-that-should-be-truncated.docx",
]; ];
// Act // Act
@@ -65,7 +65,7 @@ describe("renderEmailResponseValue", () => {
test("renders a message when overrideFileUploadResponse is true", async () => { test("renders a message when overrideFileUploadResponse is true", async () => {
// Arrange // 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"; const expectedMessage = "emails.render_email_response_value_file_upload_response_link_not_included";
// Act // Act

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 { Column, Container, Img, Link, Row, Text } from "@react-email/components";
import { TFnType } from "@tolgee/react"; import { TFnType } from "@tolgee/react";
import { FileIcon } from "lucide-react"; import { FileIcon } from "lucide-react";

View File

@@ -149,10 +149,10 @@ describe("AddApiKeyModal", () => {
test("handles label input", async () => { test("handles label input", async () => {
render(<AddApiKeyModal {...defaultProps} />); 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"); 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 () => { test("handles permission changes", async () => {
@@ -184,120 +184,21 @@ describe("AddApiKeyModal", () => {
await userEvent.click(addButton); await userEvent.click(addButton);
// Verify new permission row is added // Verify new permission row is added
const deleteButtons = await screen.findAllByRole("button", { const deleteButtons = screen.getAllByRole("button", { name: "" }); // Trash icons
name: "environments.project.api_keys.delete_permission",
});
expect(deleteButtons).toHaveLength(2); expect(deleteButtons).toHaveLength(2);
// Remove the new permission // Remove the new permission
await userEvent.click(deleteButtons[1]); await userEvent.click(deleteButtons[1]);
// Check that only the original permission row remains // Check that only the original permission row remains
const remainingDeleteButtons = await screen.findAllByRole("button", { expect(screen.getAllByRole("button", { name: "" })).toHaveLength(1);
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);
}); });
test("submits form with correct data", async () => { test("submits form with correct data", async () => {
render(<AddApiKeyModal {...defaultProps} />); render(<AddApiKeyModal {...defaultProps} />);
// Fill in label // 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"); await userEvent.type(labelInput, "Test API Key");
const addButton = screen.getByRole("button", { name: /add_permission/i }); const addButton = screen.getByRole("button", { name: /add_permission/i });
@@ -377,7 +278,7 @@ describe("AddApiKeyModal", () => {
render(<AddApiKeyModal {...defaultProps} />); render(<AddApiKeyModal {...defaultProps} />);
// Type something into the label // 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"); await userEvent.type(labelInput, "Test API Key");
// Click the cancel button // Click the cancel button
@@ -386,219 +287,6 @@ describe("AddApiKeyModal", () => {
// Verify modal is closed and form is reset // Verify modal is closed and form is reset
expect(mockSetOpen).toHaveBeenCalledWith(false); expect(mockSetOpen).toHaveBeenCalledWith(false);
expect((labelInput as HTMLInputElement).value).toBe(""); expect(labelInput.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();
}); });
}); });

View File

@@ -80,22 +80,23 @@ export const AddApiKeyModal = ({
const [selectedOrganizationAccess, setSelectedOrganizationAccess] = const [selectedOrganizationAccess, setSelectedOrganizationAccess] =
useState<TOrganizationAccess>(defaultOrganizationAccess); useState<TOrganizationAccess>(defaultOrganizationAccess);
const getInitialPermissions = (): PermissionRecord[] => { const getInitialPermissions = () => {
if (projects.length > 0 && projects[0].environments.length > 0) { if (projects.length > 0 && projects[0].environments.length > 0) {
return [ return {
{ "permission-0": {
projectId: projects[0].id, projectId: projects[0].id,
environmentId: projects[0].environments[0].id, environmentId: projects[0].environments[0].id,
permission: ApiKeyPermission.read, permission: ApiKeyPermission.read,
projectName: projects[0].name, projectName: projects[0].name,
environmentType: projects[0].environments[0].type, 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) => ({ const projectOptions: ProjectOption[] = projects.map((project) => ({
id: project.id, id: project.id,
@@ -103,54 +104,58 @@ export const AddApiKeyModal = ({
})); }));
const removePermission = (index: number) => { const removePermission = (index: number) => {
const updatedPermissions = [...selectedPermissions]; const updatedPermissions = { ...selectedPermissions };
updatedPermissions.splice(index, 1); delete updatedPermissions[`permission-${index}`];
setSelectedPermissions(updatedPermissions); setSelectedPermissions(updatedPermissions);
}; };
const addPermission = () => { const addPermission = () => {
const initialPermissions = getInitialPermissions(); const newIndex = Object.keys(selectedPermissions).length;
if (initialPermissions.length > 0) { const initialPermission = getInitialPermissions()["permission-0"];
setSelectedPermissions([...selectedPermissions, initialPermissions[0]]); if (initialPermission) {
setSelectedPermissions({
...selectedPermissions,
[`permission-${newIndex}`]: initialPermission,
});
} }
}; };
const updatePermission = (index: number, field: string, value: string) => { const updatePermission = (key: string, field: string, value: string) => {
const updatedPermissions = [...selectedPermissions]; const project = projects.find((p) => p.id === selectedPermissions[key].projectId);
const project = projects.find((p) => p.id === updatedPermissions[index].projectId);
const environment = project?.environments.find((env) => env.id === value); const environment = project?.environments.find((env) => env.id === value);
updatedPermissions[index] = { setSelectedPermissions({
...updatedPermissions[index], ...selectedPermissions,
[field]: value, [key]: {
...(field === "environmentId" && environment ? { environmentType: environment.type } : {}), ...selectedPermissions[key],
}; [field]: value,
...(field === "environmentId" && environment ? { environmentType: environment.type } : {}),
setSelectedPermissions(updatedPermissions); },
});
}; };
// Update environment when project changes // 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); const project = projects.find((p) => p.id === projectId);
if (project && project.environments.length > 0) { if (project && project.environments.length > 0) {
const environment = project.environments[0]; const environment = project.environments[0];
const updatedPermissions = [...selectedPermissions]; setSelectedPermissions({
...selectedPermissions,
updatedPermissions[index] = { [key]: {
...updatedPermissions[index], ...selectedPermissions[key],
projectId, projectId,
environmentId: environment.id, environmentId: environment.id,
projectName: project.name, projectName: project.name,
environmentType: environment.type, environmentType: environment.type,
}; },
});
setSelectedPermissions(updatedPermissions);
} }
}; };
const checkForDuplicatePermissions = () => { const checkForDuplicatePermissions = () => {
const uniquePermissions = new Set(selectedPermissions.map((p) => `${p.projectId}-${p.environmentId}`)); const permissions = Object.values(selectedPermissions);
return uniquePermissions.size !== selectedPermissions.length; const uniquePermissions = new Set(permissions.map((p) => `${p.projectId}-${p.environmentId}`));
return uniquePermissions.size !== permissions.length;
}; };
const submitAPIKey = async () => { const submitAPIKey = async () => {
@@ -162,7 +167,7 @@ export const AddApiKeyModal = ({
} }
// Convert permissions to the format expected by the API // Convert permissions to the format expected by the API
const environmentPermissions = selectedPermissions.map((permission) => ({ const environmentPermissions = Object.values(selectedPermissions).map((permission) => ({
environmentId: permission.environmentId, environmentId: permission.environmentId,
permission: permission.permission, permission: permission.permission,
})); }));
@@ -174,7 +179,7 @@ export const AddApiKeyModal = ({
}); });
reset(); reset();
setSelectedPermissions([]); setSelectedPermissions({});
setSelectedOrganizationAccess(defaultOrganizationAccess); 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 // 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) => const hasOrganizationAccess = Object.values(selectedOrganizationAccess).some((accessGroup) =>
Object.values(accessGroup).some((value) => value === true) Object.values(accessGroup).some((value) => value === true)
@@ -230,9 +235,13 @@ export const AddApiKeyModal = ({
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("environments.project.api_keys.project_access")}</Label> <Label>{t("environments.project.api_keys.project_access")}</Label>
<div className="space-y-2"> <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 ( 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"> <div className="w-1/3">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -252,7 +261,7 @@ export const AddApiKeyModal = ({
<DropdownMenuItem <DropdownMenuItem
key={option.id} key={option.id}
onClick={() => { onClick={() => {
updateProjectAndEnvironment(index, option.id); updateProjectAndEnvironment(key, option.id);
}}> }}>
{option.name} {option.name}
</DropdownMenuItem> </DropdownMenuItem>
@@ -260,6 +269,8 @@ export const AddApiKeyModal = ({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
{/* Environment dropdown */}
<div className="w-1/3"> <div className="w-1/3">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -281,7 +292,7 @@ export const AddApiKeyModal = ({
<DropdownMenuItem <DropdownMenuItem
key={env.id} key={env.id}
onClick={() => { onClick={() => {
updatePermission(index, "environmentId", env.id); updatePermission(key, "environmentId", env.id);
}}> }}>
{env.type} {env.type}
</DropdownMenuItem> </DropdownMenuItem>
@@ -289,6 +300,8 @@ export const AddApiKeyModal = ({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
{/* Permission level dropdown */}
<div className="w-1/3"> <div className="w-1/3">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -310,7 +323,7 @@ export const AddApiKeyModal = ({
<DropdownMenuItem <DropdownMenuItem
key={option} key={option}
onClick={() => { onClick={() => {
updatePermission(index, "permission", option); updatePermission(key, "permission", option);
}}> }}>
{option} {option}
</DropdownMenuItem> </DropdownMenuItem>
@@ -318,16 +331,16 @@ export const AddApiKeyModal = ({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<button
type="button" {/* Delete button */}
className="p-2" <button type="button" className="p-2" onClick={() => removePermission(permissionIndex)}>
onClick={() => removePermission(index)}
aria-label={t("environments.project.api_keys.delete_permission")}>
<Trash2Icon className={"h-5 w-5 text-slate-500 hover:text-red-500"} /> <Trash2Icon className={"h-5 w-5 text-slate-500 hover:text-red-500"} />
</button> </button>
</div> </div>
); );
})} })}
{/* Add permission button */}
<Button type="button" variant="outline" onClick={addPermission}> <Button type="button" variant="outline" onClick={addPermission}>
<span className="mr-2">+</span> {t("environments.settings.api_keys.add_permission")} <span className="mr-2">+</span> {t("environments.settings.api_keys.add_permission")}
</Button> </Button>
@@ -384,7 +397,7 @@ export const AddApiKeyModal = ({
onClick={() => { onClick={() => {
setOpen(false); setOpen(false);
reset(); reset();
setSelectedPermissions([]); setSelectedPermissions({});
}}> }}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>

View File

@@ -1,9 +1,10 @@
import { createEnvironment } from "@/lib/environment/service"; 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 { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { StorageErrorCode } from "@formbricks/storage";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors"; import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
import { ZProject } from "@formbricks/types/project"; import { ZProject } from "@formbricks/types/project";
@@ -64,22 +65,14 @@ vi.mock("@formbricks/logger", () => ({
}, },
})); }));
vi.mock("@/lib/storage/service", () => ({ vi.mock("@/modules/storage/service", () => ({
deleteLocalFilesByEnvironmentId: vi.fn(), deleteFilesByEnvironmentId: vi.fn(),
deleteS3FilesByEnvironmentId: vi.fn(),
})); }));
vi.mock("@/lib/environment/service", () => ({ vi.mock("@/lib/environment/service", () => ({
createEnvironment: vi.fn(), createEnvironment: vi.fn(),
})); }));
let mockIsS3Configured = true;
vi.mock("@/lib/constants", () => ({
isS3Configured: () => {
return mockIsS3Configured;
},
}));
describe("project lib", () => { describe("project lib", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -156,28 +149,21 @@ describe("project lib", () => {
}); });
describe("deleteProject", () => { 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(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"); const result = await deleteProject("p1");
expect(result).toEqual(baseProject); expect(result).toEqual(baseProject);
expect(deleteS3FilesByEnvironmentId).toHaveBeenCalledWith("prodenv"); expect(deleteFilesByEnvironmentId).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");
}); });
test("logs error if file deletion fails", async () => { test("logs error if file deletion fails", async () => {
vi.mocked(prisma.project.delete).mockResolvedValueOnce(baseProject as any); vi.mocked(prisma.project.delete).mockResolvedValueOnce(baseProject as any);
mockIsS3Configured = true; vi.mocked(deleteFilesByEnvironmentId).mockResolvedValue({
vi.mocked(deleteS3FilesByEnvironmentId).mockRejectedValueOnce(new Error("fail")); ok: false,
error: { code: StorageErrorCode.Unknown },
} as any);
vi.mocked(logger.error).mockImplementation(() => {}); vi.mocked(logger.error).mockImplementation(() => {});
await deleteProject("p1"); await deleteProject("p1");
expect(logger.error).toHaveBeenCalled(); expect(logger.error).toHaveBeenCalled();

View File

@@ -1,8 +1,7 @@
import "server-only"; import "server-only";
import { isS3Configured } from "@/lib/constants";
import { createEnvironment } from "@/lib/environment/service"; import { createEnvironment } from "@/lib/environment/service";
import { deleteLocalFilesByEnvironmentId, deleteS3FilesByEnvironmentId } from "@/lib/storage/service";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { deleteFilesByEnvironmentId } from "@/modules/storage/service";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
@@ -141,27 +140,16 @@ export const deleteProject = async (projectId: string): Promise<TProject> => {
if (project) { if (project) {
// delete all files from storage related to this project // delete all files from storage related to this project
if (isS3Configured()) { const s3FilesPromises = project.environments.map(async (environment) => {
const s3FilesPromises = project.environments.map(async (environment) => { return deleteFilesByEnvironmentId(environment.id);
return deleteS3FilesByEnvironmentId(environment.id); });
});
try { const s3FilesResult = await Promise.all(s3FilesPromises);
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);
});
try { for (const result of s3FilesResult) {
await Promise.all(localFilesPromises); if (!result.ok) {
} catch (err) {
// fail silently because we don't want to throw an error if the files are not deleted // 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) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
} }
throw error; throw error;
} }
}; };

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { handleFileUpload } from "@/app/lib/fileUpload";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateProjectAction } from "@/modules/projects/settings/actions"; import { updateProjectAction } from "@/modules/projects/settings/actions";
import { handleFileUpload } from "@/modules/storage/file-upload";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle"; import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription } from "@/modules/ui/components/alert"; import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";

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"; import { Toaster } from "react-hot-toast";
export const SetupLayout = ({ children }: { children: React.ReactNode }) => { export const SetupLayout = ({ children }: { children: React.ReactNode }) => {
@@ -10,7 +10,7 @@ export const SetupLayout = ({ children }: { children: React.ReactNode }) => {
style={{ scrollbarGutter: "stable both-edges" }} 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"> 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"> <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> </div>
{children} {children}
</div> </div>

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as fileUploadModule from "./fileUpload"; import * as fileUploadModule from "./file-upload";
// Mock global fetch // Mock global fetch
const mockFetch = vi.fn(); const mockFetch = vi.fn();
@@ -44,13 +44,6 @@ describe("fileUpload", () => {
expect(result.url).toBe(""); 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 () => { 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 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"); 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 () => { test("should handle upload error with presigned fields", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000); const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Mock successful API response // Mock successful API response

View File

@@ -0,0 +1,147 @@
import toast from "react-hot-toast";
export enum FileUploadError {
NO_FILE = "No file provided or invalid file type. Expected a File or Blob.",
INVALID_FILE_TYPE = "Please upload an image file.",
FILE_SIZE_EXCEEDED = "File size must be less than 10 MB.",
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) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = reject;
});
export const handleFileUpload = async (
file: File,
environmentId: string,
allowedFileExtensions?: string[]
): Promise<{
error?: FileUploadError;
url: string;
}> => {
try {
if (!(file instanceof File)) {
return {
error: FileUploadError.NO_FILE,
url: "",
};
}
const fileBuffer = await file.arrayBuffer();
const bufferBytes = fileBuffer.byteLength;
const bufferKB = bufferBytes / 1024;
if (bufferKB > 10240) {
return {
error: FileUploadError.FILE_SIZE_EXCEEDED,
url: "",
};
}
const payload = {
fileName: file.name,
fileType: file.type,
allowedFileExtensions,
environmentId,
};
const response = await fetch("/api/v1/management/storage", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
// get the error and add it to the body:
const error = await response.json();
document.body.innerHTML += `<div class="text-red-500 absolute top-0 left-0 mb-10 bg-white p-4 rounded-md">${JSON.stringify(error)}</div>`;
toast.error(JSON.stringify(error), { duration: 10000 });
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: "",
};
}
const json = await response.json();
const { data } = json;
const { signedUrl, fileUrl, presignedFields } = data as {
signedUrl: string;
presignedFields: Record<string, string>;
fileUrl: string;
};
const fileBase64 = (await toBase64(file)) as string;
const formDataForS3 = new FormData();
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("Error in uploading file: ", err);
document.body.innerHTML += `<div class="text-red-500 absolute top-0 left-0 mb-10 bg-white p-4 rounded-md">${JSON.stringify(err)}</div>`;
toast.error(JSON.stringify(err), { duration: 10000 });
return {
error: FileUploadError.UPLOAD_FAILED,
url: "",
};
}
toast.success("Uploading file...", { duration: 10000 });
toast.success(signedUrl, { duration: 10000 });
const uploadResponse = await fetch(signedUrl, {
method: "POST",
body: formDataForS3,
});
if (!uploadResponse.ok) {
const error = await uploadResponse.json();
const status = uploadResponse.status;
document.body.innerHTML += `<div class="text-red-500 absolute top-0 left-0 mb-10 bg-white p-4 rounded-md">${JSON.stringify(error)} ${status}</div>`;
toast.error(JSON.stringify(error), { duration: 10000 });
toast.error(status.toString(), { duration: 10000 });
return {
error: FileUploadError.UPLOAD_FAILED,
url: "",
};
}
return {
url: fileUrl,
};
} catch (error) {
console.error("Error in uploading file: ", error);
document.body.innerHTML += `<div class="text-red-500 absolute top-0 left-0 mb-10 bg-white p-4 rounded-md">${JSON.stringify(error)}</div>`;
toast.error(JSON.stringify(error), { duration: 10000 });
return {
error: FileUploadError.UPLOAD_FAILED,
url: "",
};
}
};

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");
});
});
});

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);

View File

@@ -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 { import {
isAllowedFileExtension, isAllowedFileExtension,
isValidFileTypeForExtension, isValidFileTypeForExtension,
isValidImageFile, isValidImageFile,
validateFile, sanitizeFileName,
validateFileUploads, validateFileUploads,
validateSingleFile, 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 // Mock the getOriginalFileNameFromUrl function
vi.mock("@/lib/storage/utils", () => ({ const mockGetOriginalFileNameFromUrl = vi.hoisted(() => vi.fn());
getOriginalFileNameFromUrl: vi.fn((url) => {
// Extract filename from the URL for testing purposes vi.mock("@/modules/storage/utils", async () => {
const parts = url.split("/"); const actual = await vi.importActual("@/modules/storage/utils");
return parts[parts.length - 1]; 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", () => { describe("isAllowedFileExtension", () => {
test("should return false for a file with no extension", () => { test("should return false for a file with no extension", () => {
expect(isAllowedFileExtension("filename")).toBe(false); 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", () => { describe("validateSingleFile", () => {
test("should return true for allowed file extension", () => { 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); expect(validateSingleFile("https://example.com/image.jpg", ["jpg", "png"])).toBe(true);
}); });
test("should return false for disallowed file extension", () => { 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); expect(validateSingleFile("https://example.com/malicious.exe", ["jpg", "png"])).toBe(false);
}); });
test("should return true when no allowed extensions are specified", () => { 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); 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", () => { 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); 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", () => { test("should return false when file name cannot be extracted", () => {
// Mock implementation to return null for this specific URL // Mock implementation to return null for this specific URL
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => undefined); mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => undefined);
const responseData = { const responseData = {
question1: ["https://example.com/invalid-url"], question1: ["https://example.com/invalid-url"],
@@ -227,9 +314,7 @@ describe("fileValidation", () => {
}); });
test("should return false when file has no extension", () => { test("should return false when file has no extension", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce( mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => "file-without-extension");
() => "file-without-extension"
);
const responseData = { const responseData = {
question1: ["https://example.com/storage/file-without-extension"], question1: ["https://example.com/storage/file-without-extension"],
@@ -292,24 +377,22 @@ describe("fileValidation", () => {
}); });
test("should return false when file name cannot be extracted", () => { 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); expect(isValidImageFile("https://example.com/invalid-url")).toBe(false);
}); });
test("should return false when file has no extension", () => { test("should return false when file has no extension", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce( mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => "image-without-extension");
() => "image-without-extension"
);
expect(isValidImageFile("https://example.com/image-without-extension")).toBe(false); expect(isValidImageFile("https://example.com/image-without-extension")).toBe(false);
}); });
test("should return false when file name ends with a dot", () => { 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); expect(isValidImageFile("https://example.com/image.")).toBe(false);
}); });
test("should handle case insensitivity correctly", () => { 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); expect(isValidImageFile("https://example.com/image.JPG")).toBe(true);
}); });
}); });

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);
}
}
};

View File

@@ -6,7 +6,7 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import { updateUser } from "./user"; import { updateUser } from "./user";
vi.mock("@/lib/fileValidation", () => ({ vi.mock("@/modules/storage/utils", () => ({
isValidImageFile: vi.fn(), isValidImageFile: vi.fn(),
})); }));

View File

@@ -13,7 +13,7 @@ import { PlusIcon, XCircleIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { type JSX, useMemo, useState } from "react"; import { type JSX, useMemo, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/common"; import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/storage";
import { TSurvey, TSurveyFileUploadQuestion } from "@formbricks/types/surveys/types"; import { TSurvey, TSurveyFileUploadQuestion } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";

View File

@@ -41,8 +41,8 @@ vi.mock("next/link", () => ({
), ),
})); }));
vi.mock("@/modules/ui/components/logo", () => ({ vi.mock("@/modules/ui/components/formbricks-logo", () => ({
Logo: () => <div data-testid="logo">Logo</div>, FormbricksLogo: () => <div data-testid="formbricks-logo">FormbricksLogo</div>,
})); }));
vi.mock("@/modules/ui/components/button", () => ({ vi.mock("@/modules/ui/components/button", () => ({

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Logo } from "@/modules/ui/components/logo"; import { FormbricksLogo } from "@/modules/ui/components/formbricks-logo";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import Image, { StaticImageData } from "next/image"; import Image, { StaticImageData } from "next/image";
import Link from "next/link"; import Link from "next/link";
@@ -51,7 +51,7 @@ export const ConnectIntegration = ({
<div className="flex w-1/2 flex-col items-center justify-center rounded-lg bg-white p-8 shadow"> <div className="flex w-1/2 flex-col items-center justify-center rounded-lg bg-white p-8 shadow">
<div className="flex w-1/2 justify-center -space-x-4"> <div className="flex w-1/2 justify-center -space-x-4">
<div className="flex h-32 w-32 items-center justify-center rounded-full bg-white p-6 shadow-md"> <div className="flex h-32 w-32 items-center justify-center rounded-full bg-white p-6 shadow-md">
<Logo variant="image" /> <FormbricksLogo />
</div> </div>
<div className="flex h-32 w-32 items-center justify-center rounded-full bg-white p-4 shadow-md"> <div className="flex h-32 w-32 items-center justify-center rounded-full bg-white p-4 shadow-md">
<Image className="w-1/2" src={integrationLogoSrc} alt="logo" /> <Image className="w-1/2" src={integrationLogoSrc} alt="logo" />

View File

@@ -1,7 +1,7 @@
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { TAllowedFileExtension } from "@formbricks/types/common"; import { TAllowedFileExtension } from "@formbricks/types/storage";
import { Uploader } from "./uploader"; import { Uploader } from "./uploader";
describe("Uploader", () => { describe("Uploader", () => {

View File

@@ -1,7 +1,7 @@
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { ArrowUpFromLineIcon } from "lucide-react"; import { ArrowUpFromLineIcon } from "lucide-react";
import React from "react"; import React from "react";
import { TAllowedFileExtension } from "@formbricks/types/common"; import { TAllowedFileExtension } from "@formbricks/types/storage";
interface UploaderProps { interface UploaderProps {
id: string; id: string;

View File

@@ -1,11 +1,20 @@
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { TAllowedFileExtension } from "@formbricks/types/common"; import { TAllowedFileExtension } from "@formbricks/types/storage";
import { FileInput } from "./index"; import { FileInput } from "./index";
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
},
error: vi.fn(),
}));
// Mock dependencies // Mock dependencies
vi.mock("@/app/lib/fileUpload", () => ({ vi.mock("@/modules/storage/file-upload", () => ({
FileUploadError: { INVALID_FILE_NAME: "Invalid file name. Please rename your file and try again." },
handleFileUpload: vi.fn().mockResolvedValue({ url: "https://example.com/uploaded-file.jpg" }), handleFileUpload: vi.fn().mockResolvedValue({ url: "https://example.com/uploaded-file.jpg" }),
})); }));
@@ -48,6 +57,26 @@ describe("FileInput", () => {
expect(screen.getByPlaceholderText("https://www.youtube.com/watch?v=VIDEO_ID")).toBeInTheDocument(); expect(screen.getByPlaceholderText("https://www.youtube.com/watch?v=VIDEO_ID")).toBeInTheDocument();
}); });
test("shows invalid filename toast when upload returns INVALID_FILE_NAME", async () => {
const mod = await import("@/modules/storage/file-upload");
vi.mocked(mod.handleFileUpload as any).mockResolvedValueOnce({
error: mod.FileUploadError.INVALID_FILE_NAME,
url: "",
});
render(<FileInput {...defaultProps} />);
const input = screen.getByTestId("upload-file-input");
const file = new File(["dummy"], "----.png", { type: "image/png" });
const utils = await import("./lib/utils");
vi.mocked(utils.getAllowedFiles as any).mockResolvedValueOnce([file]);
await userEvent.upload(input, file);
// allow async handlers to finish
await new Promise((r) => setTimeout(r, 0));
expect((toast as any).error).toHaveBeenCalled();
});
test("displays existing file when fileUrl is provided", () => { test("displays existing file when fileUrl is provided", () => {
const fileUrl = "https://example.com/test-image.jpg"; const fileUrl = "https://example.com/test-image.jpg";
render(<FileInput {...defaultProps} fileUrl={fileUrl} />); render(<FileInput {...defaultProps} fileUrl={fileUrl} />);

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { handleFileUpload } from "@/app/lib/fileUpload";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { FileUploadError, handleFileUpload } from "@/modules/storage/file-upload";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner"; import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { OptionsSwitch } from "@/modules/ui/components/options-switch"; import { OptionsSwitch } from "@/modules/ui/components/options-switch";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
@@ -9,7 +9,7 @@ import { FileIcon, XIcon } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { TAllowedFileExtension } from "@formbricks/types/common"; import { TAllowedFileExtension } from "@formbricks/types/storage";
import { Uploader } from "./components/uploader"; import { Uploader } from "./components/uploader";
import { VideoSettings } from "./components/video-settings"; import { VideoSettings } from "./components/video-settings";
import { getAllowedFiles } from "./lib/utils"; import { getAllowedFiles } from "./lib/utils";
@@ -84,7 +84,11 @@ export const FileInput = ({
); );
if (uploadedFiles.length < allowedFiles.length || uploadedFiles.some((file) => file.error)) { if (uploadedFiles.length < allowedFiles.length || uploadedFiles.some((file) => file.error)) {
if (uploadedFiles.length === 0) { const firstError = uploadedFiles.find((f) => f.error)?.error;
toast.error(JSON.stringify(uploadedFiles), { duration: 1000 });
if (firstError === FileUploadError.INVALID_FILE_NAME) {
toast.error(t("common.invalid_file_name"));
} else if (uploadedFiles.length === 0) {
toast.error(t("common.no_files_uploaded")); toast.error(t("common.no_files_uploaded"));
} else { } else {
toast.error(t("common.some_files_failed_to_upload")); toast.error(t("common.some_files_failed_to_upload"));
@@ -150,7 +154,11 @@ export const FileInput = ({
); );
if (uploadedFiles.length < allowedFiles.length || uploadedFiles.some((file) => file.error)) { if (uploadedFiles.length < allowedFiles.length || uploadedFiles.some((file) => file.error)) {
if (uploadedFiles.length === 0) { const firstError = uploadedFiles.find((f) => f.error)?.error;
toast.error(JSON.stringify(uploadedFiles), { duration: 1000 });
if (firstError === FileUploadError.INVALID_FILE_NAME) {
toast.error(t("common.invalid_file_name"));
} else if (uploadedFiles.length === 0) {
toast.error(t("common.no_files_uploaded")); toast.error(t("common.no_files_uploaded"));
} else { } else {
toast.error(t("common.some_files_failed_to_upload")); toast.error(t("common.some_files_failed_to_upload"));

View File

@@ -1,6 +1,6 @@
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { TAllowedFileExtension } from "@formbricks/types/common"; import { TAllowedFileExtension } from "@formbricks/types/storage";
import { convertHeicToJpegAction } from "./actions"; import { convertHeicToJpegAction } from "./actions";
import { checkForYoutubePrivacyMode, getAllowedFiles } from "./utils"; import { checkForYoutubePrivacyMode, getAllowedFiles } from "./utils";

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { TAllowedFileExtension } from "@formbricks/types/common"; import { TAllowedFileExtension } from "@formbricks/types/storage";
import { convertHeicToJpegAction } from "./actions"; import { convertHeicToJpegAction } from "./actions";
const isFileSizeExceed = (fileSizeInMB: number, maxSizeInMB?: number) => { const isFileSizeExceed = (fileSizeInMB: number, maxSizeInMB?: number) => {

View File

@@ -30,7 +30,7 @@ describe("FileUploadResponse", () => {
}); });
test("renders 'Download' when filename cannot be extracted", () => { test("renders 'Download' when filename cannot be extracted", () => {
const fileUrls = ["http://example.com/unknown-file"]; const fileUrls = [""];
render(<FileUploadResponse selected={fileUrls} />); render(<FileUploadResponse selected={fileUrls} />);
expect(screen.getByText("Download")).toBeInTheDocument(); expect(screen.getByText("Download")).toBeInTheDocument();

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { getOriginalFileNameFromUrl } from "@/lib/storage/utils"; import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { DownloadIcon } from "lucide-react"; import { DownloadIcon } from "lucide-react";

View File

@@ -0,0 +1,197 @@
interface FormbricksLogoProps {
className?: string;
}
export const FormbricksLogo = ({ className }: FormbricksLogoProps) => {
return (
<svg
width="220"
height="220"
viewBox="0 0 220 220"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}>
<path
d="M39.1602 147.334H95.8321V175.67C95.8321 191.32 83.1457 204.006 67.4962 204.006C51.8466 204.006 39.1602 191.32 39.1602 175.67V147.334Z"
fill="url(#paint0_linear_415_2)"
/>
<path
d="M39.1602 81.8071H152.504C168.154 81.8071 180.84 94.4936 180.84 110.143C180.84 125.793 168.154 138.479 152.504 138.479H39.1602V81.8071Z"
fill="url(#paint1_linear_415_2)"
/>
<path
d="M39.1602 62.7322C39.1602 37.0773 59.9576 16.2798 85.6126 16.2798H152.504C168.154 16.2798 180.84 28.9662 180.84 44.6158C180.84 60.2653 168.154 72.9518 152.504 72.9518H39.1602V62.7322Z"
fill="url(#paint2_linear_415_2)"
/>
<mask
id="mask0_415_2"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="39"
y="16"
width="142"
height="189">
<path
d="M39.1602 147.335H95.8321V175.671C95.8321 191.32 83.1457 204.007 67.4962 204.007C51.8466 204.007 39.1602 191.32 39.1602 175.671V147.335Z"
fill="url(#paint3_linear_415_2)"
/>
<path
d="M39.1602 81.8081H152.504C168.154 81.8081 180.84 94.4946 180.84 110.144C180.84 125.794 168.154 138.48 152.504 138.48H39.1602V81.8081Z"
fill="url(#paint4_linear_415_2)"
/>
<path
d="M39.1602 62.7322C39.1602 37.0773 59.9576 16.2798 85.6126 16.2798H152.504C168.154 16.2798 180.84 28.9662 180.84 44.6158C180.84 60.2653 168.154 72.9518 152.504 72.9518H39.1602V62.7322Z"
fill="url(#paint5_linear_415_2)"
/>
</mask>
<g mask="url(#mask0_415_2)">
<g filter="url(#filter0_d_415_2)">
<mask
id="mask1_415_2"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="39"
y="16"
width="142"
height="189">
<path
d="M39.1602 147.335H95.8321V175.671C95.8321 191.32 83.1457 204.007 67.4962 204.007C51.8466 204.007 39.1602 191.32 39.1602 175.671V147.335Z"
fill="black"
fillOpacity="0.1"
/>
<path
d="M39.1602 62.7322C39.1602 37.0773 59.9576 16.2798 85.6126 16.2798H152.504C168.154 16.2798 180.84 28.9662 180.84 44.6158C180.84 60.2653 168.154 72.9518 152.504 72.9518H39.1602V62.7322Z"
fill="black"
fillOpacity="0.1"
/>
<path
d="M39.1602 81.8081H152.504C168.154 81.8081 180.84 94.4946 180.84 110.144C180.84 125.794 168.154 138.48 152.504 138.48H39.1602V81.8081Z"
fill="black"
fillOpacity="0.1"
/>
</mask>
<g mask="url(#mask1_415_2)">
<path
d="M42.1331 -32.5321C64.3329 -54.1986 120.626 -32.5321 120.626 -32.5321H42.1331C36.6806 -27.2105 33.2847 -19.2749 33.2847 -7.76218C33.2847 50.6243 96.5317 71.8561 96.5317 112.55C96.5317 152.386 35.9231 176.962 33.3678 231.092H120.626C120.626 231.092 33.2847 291.248 33.2847 234.631C33.2847 233.437 33.3128 232.258 33.3678 231.092H-5.11523L2.41417 -32.5321H42.1331Z"
fill="black"
fillOpacity="0.1"
/>
</g>
</g>
<g filter="url(#filter1_f_415_2)">
<circle cx="21.4498" cy="179.212" r="53.13" fill="#00C4B8" />
</g>
<g filter="url(#filter2_f_415_2)">
<circle cx="21.4498" cy="44.6163" r="53.13" fill="#00C4B8" />
</g>
</g>
<defs>
<filter
id="filter0_d_415_2"
x="34.5149"
y="-11.5917"
width="137.209"
height="243.47"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="23.2262" />
<feGaussianBlur stdDeviation="13.9357" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_415_2" />
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_415_2" result="shape" />
</filter>
<filter
id="filter1_f_415_2"
x="-78.1326"
y="79.6296"
width="199.165"
height="199.165"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="23.2262" result="effect1_foregroundBlur_415_2" />
</filter>
<filter
id="filter2_f_415_2"
x="-78.1326"
y="-54.9661"
width="199.165"
height="199.165"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="23.2262" result="effect1_foregroundBlur_415_2" />
</filter>
<linearGradient
id="paint0_linear_415_2"
x1="96.0786"
y1="174.643"
x2="39.1553"
y2="174.873"
gradientUnits="userSpaceOnUse">
<stop offset="1" stopColor="#00C4B8" />
</linearGradient>
<linearGradient
id="paint1_linear_415_2"
x1="181.456"
y1="109.116"
x2="39.1602"
y2="110.554"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00DDD0" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
<linearGradient
id="paint2_linear_415_2"
x1="181.456"
y1="43.5891"
x2="39.1602"
y2="45.0264"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00DDD0" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
<linearGradient
id="paint3_linear_415_2"
x1="96.0786"
y1="174.644"
x2="39.1553"
y2="174.874"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00FFE1" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
<linearGradient
id="paint4_linear_415_2"
x1="181.456"
y1="109.117"
x2="39.1602"
y2="110.555"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00FFE1" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
<linearGradient
id="paint5_linear_415_2"
x1="181.456"
y1="43.5891"
x2="39.1602"
y2="45.0264"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00FFE1" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
</defs>
</svg>
);
};

View File

@@ -8,59 +8,33 @@ describe("Logo", () => {
cleanup(); cleanup();
}); });
describe("default variant", () => { test("renders correctly", () => {
test("renders default logo correctly", () => { const { container } = render(<Logo />);
const { container } = render(<Logo />); const svg = container.querySelector("svg");
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument(); expect(svg).toBeInTheDocument();
}); expect(svg).toHaveAttribute("viewBox", "0 0 697 150");
expect(svg).toHaveAttribute("fill", "none");
expect(svg).toHaveAttribute("xmlns", "http://www.w3.org/2000/svg");
}); });
describe("image variant", () => { test("accepts and passes through props", () => {
test("renders image logo correctly", () => { const testClassName = "test-class";
const { container } = render(<Logo variant="image" />); const { container } = render(<Logo className={testClassName} />);
const svg = container.querySelector("svg"); const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument(); expect(svg).toBeInTheDocument();
}); expect(svg).toHaveAttribute("class", testClassName);
test("renders image logo with className correctly", () => {
const testClassName = "test-class";
const { container } = render(<Logo variant="image" className={testClassName} />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
expect(svg).toHaveAttribute("class", testClassName);
});
}); });
describe("wordmark variant", () => { test("contains expected svg elements", () => {
test("renders wordmark logo correctly", () => { const { container } = render(<Logo />);
const { container } = render(<Logo variant="wordmark" />); const svg = container.querySelector("svg");
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument(); expect(svg?.querySelectorAll("path").length).toBeGreaterThan(0);
}); expect(svg?.querySelector("line")).toBeInTheDocument();
expect(svg?.querySelectorAll("mask").length).toBe(2);
test("renders wordmark logo with className correctly", () => { expect(svg?.querySelectorAll("filter").length).toBe(3);
const testClassName = "test-class"; expect(svg?.querySelectorAll("linearGradient").length).toBe(6);
const { container } = render(<Logo variant="wordmark" className={testClassName} />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
expect(svg).toHaveAttribute("class", testClassName);
});
test("contains expected svg elements", () => {
const { container } = render(<Logo variant="wordmark" />);
const svg = container.querySelector("svg");
expect(svg?.querySelectorAll("path").length).toBeGreaterThan(0);
expect(svg?.querySelector("line")).toBeInTheDocument();
expect(svg?.querySelectorAll("mask").length).toBe(2);
expect(svg?.querySelectorAll("filter").length).toBe(3);
expect(svg?.querySelectorAll("linearGradient").length).toBe(6);
});
}); });
}); });

View File

@@ -1,208 +1,4 @@
interface LogoProps extends React.SVGProps<SVGSVGElement> { export const Logo = (props: any) => {
variant?: "image" | "wordmark";
}
export const Logo = ({ variant = "wordmark", ...props }: LogoProps) => {
if (variant === "image") return <ImageLogo {...props} />;
return <WordmarkLogo {...props} />;
};
const ImageLogo = (props: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="220"
height="220"
viewBox="0 0 220 220"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}>
<path
d="M39.1602 147.334H95.8321V175.67C95.8321 191.32 83.1457 204.006 67.4962 204.006C51.8466 204.006 39.1602 191.32 39.1602 175.67V147.334Z"
fill="url(#paint0_linear_415_2)"
/>
<path
d="M39.1602 81.8071H152.504C168.154 81.8071 180.84 94.4936 180.84 110.143C180.84 125.793 168.154 138.479 152.504 138.479H39.1602V81.8071Z"
fill="url(#paint1_linear_415_2)"
/>
<path
d="M39.1602 62.7322C39.1602 37.0773 59.9576 16.2798 85.6126 16.2798H152.504C168.154 16.2798 180.84 28.9662 180.84 44.6158C180.84 60.2653 168.154 72.9518 152.504 72.9518H39.1602V62.7322Z"
fill="url(#paint2_linear_415_2)"
/>
<mask
id="mask0_415_2"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="39"
y="16"
width="142"
height="189">
<path
d="M39.1602 147.335H95.8321V175.671C95.8321 191.32 83.1457 204.007 67.4962 204.007C51.8466 204.007 39.1602 191.32 39.1602 175.671V147.335Z"
fill="url(#paint3_linear_415_2)"
/>
<path
d="M39.1602 81.8081H152.504C168.154 81.8081 180.84 94.4946 180.84 110.144C180.84 125.794 168.154 138.48 152.504 138.48H39.1602V81.8081Z"
fill="url(#paint4_linear_415_2)"
/>
<path
d="M39.1602 62.7322C39.1602 37.0773 59.9576 16.2798 85.6126 16.2798H152.504C168.154 16.2798 180.84 28.9662 180.84 44.6158C180.84 60.2653 168.154 72.9518 152.504 72.9518H39.1602V62.7322Z"
fill="url(#paint5_linear_415_2)"
/>
</mask>
<g mask="url(#mask0_415_2)">
<g filter="url(#filter0_d_415_2)">
<mask
id="mask1_415_2"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="39"
y="16"
width="142"
height="189">
<path
d="M39.1602 147.335H95.8321V175.671C95.8321 191.32 83.1457 204.007 67.4962 204.007C51.8466 204.007 39.1602 191.32 39.1602 175.671V147.335Z"
fill="black"
fillOpacity="0.1"
/>
<path
d="M39.1602 62.7322C39.1602 37.0773 59.9576 16.2798 85.6126 16.2798H152.504C168.154 16.2798 180.84 28.9662 180.84 44.6158C180.84 60.2653 168.154 72.9518 152.504 72.9518H39.1602V62.7322Z"
fill="black"
fillOpacity="0.1"
/>
<path
d="M39.1602 81.8081H152.504C168.154 81.8081 180.84 94.4946 180.84 110.144C180.84 125.794 168.154 138.48 152.504 138.48H39.1602V81.8081Z"
fill="black"
fillOpacity="0.1"
/>
</mask>
<g mask="url(#mask1_415_2)">
<path
d="M42.1331 -32.5321C64.3329 -54.1986 120.626 -32.5321 120.626 -32.5321H42.1331C36.6806 -27.2105 33.2847 -19.2749 33.2847 -7.76218C33.2847 50.6243 96.5317 71.8561 96.5317 112.55C96.5317 152.386 35.9231 176.962 33.3678 231.092H120.626C120.626 231.092 33.2847 291.248 33.2847 234.631C33.2847 233.437 33.3128 232.258 33.3678 231.092H-5.11523L2.41417 -32.5321H42.1331Z"
fill="black"
fillOpacity="0.1"
/>
</g>
</g>
<g filter="url(#filter1_f_415_2)">
<circle cx="21.4498" cy="179.212" r="53.13" fill="#00C4B8" />
</g>
<g filter="url(#filter2_f_415_2)">
<circle cx="21.4498" cy="44.6163" r="53.13" fill="#00C4B8" />
</g>
</g>
<defs>
<filter
id="filter0_d_415_2"
x="34.5149"
y="-11.5917"
width="137.209"
height="243.47"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="23.2262" />
<feGaussianBlur stdDeviation="13.9357" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_415_2" />
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_415_2" result="shape" />
</filter>
<filter
id="filter1_f_415_2"
x="-78.1326"
y="79.6296"
width="199.165"
height="199.165"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="23.2262" result="effect1_foregroundBlur_415_2" />
</filter>
<filter
id="filter2_f_415_2"
x="-78.1326"
y="-54.9661"
width="199.165"
height="199.165"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="23.2262" result="effect1_foregroundBlur_415_2" />
</filter>
<linearGradient
id="paint0_linear_415_2"
x1="96.0786"
y1="174.643"
x2="39.1553"
y2="174.873"
gradientUnits="userSpaceOnUse">
<stop offset="1" stopColor="#00C4B8" />
</linearGradient>
<linearGradient
id="paint1_linear_415_2"
x1="181.456"
y1="109.116"
x2="39.1602"
y2="110.554"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00DDD0" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
<linearGradient
id="paint2_linear_415_2"
x1="181.456"
y1="43.5891"
x2="39.1602"
y2="45.0264"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00DDD0" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
<linearGradient
id="paint3_linear_415_2"
x1="96.0786"
y1="174.644"
x2="39.1553"
y2="174.874"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00FFE1" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
<linearGradient
id="paint4_linear_415_2"
x1="181.456"
y1="109.117"
x2="39.1602"
y2="110.555"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00FFE1" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
<linearGradient
id="paint5_linear_415_2"
x1="181.456"
y1="43.5891"
x2="39.1602"
y2="45.0264"
gradientUnits="userSpaceOnUse">
<stop stopColor="#00FFE1" />
<stop offset="1" stopColor="#01E0C6" />
</linearGradient>
</defs>
</svg>
);
};
const WordmarkLogo = (props: React.SVGProps<SVGSVGElement>) => {
return ( return (
<svg viewBox="0 0 697 150" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}> <svg viewBox="0 0 697 150" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path <path

View File

@@ -1,74 +0,0 @@
import { Meta, StoryObj } from "@storybook/react-vite";
import { Logo } from "./index";
type StoryProps = React.ComponentProps<typeof Logo>;
const meta: Meta<StoryProps> = {
title: "UI/Logo",
component: Logo,
tags: ["autodocs"],
parameters: {
layout: "centered",
controls: { sort: "alpha", exclude: [] },
docs: {
description: {
component:
"** Logo ** renders the Formbricks brand as scalable SVG.It supports two variants('image' and 'wordmark') and is suitable for headers, navigation, and other branding areas.",
},
},
},
argTypes: {
variant: {
control: "select",
options: ["image", "wordmark"],
description: "The variant of the logo to display",
table: {
category: "Appearance",
type: { summary: "string" },
defaultValue: { summary: "wordmark" },
},
order: 1,
},
className: {
control: "text",
description: "Additional CSS classes for styling",
table: {
category: "Appearance",
type: { summary: "string" },
},
order: 1,
},
},
};
export default meta;
type Story = StoryObj<StoryProps>;
const renderLogoWithOptions = (args: StoryProps) => {
const { ...logoProps } = args;
return <Logo {...logoProps} />;
};
export const Default: Story = {
render: renderLogoWithOptions,
args: {
className: "h-20",
},
};
export const Image: Story = {
render: renderLogoWithOptions,
args: {
className: "h-20",
variant: "image",
},
};
export const Wordmark: Story = {
render: renderLogoWithOptions,
args: {
className: "h-20",
variant: "wordmark",
},
};

View File

@@ -97,7 +97,7 @@ describe("MediaBackground", () => {
styling: { styling: {
background: { background: {
bgType: "upload", bgType: "upload",
bg: "/uploads/test-image.jpg", bg: "/files/test-image.jpg",
brightness: 100, brightness: 100,
}, },
} as TProjectStyling, } as TProjectStyling,
@@ -106,7 +106,7 @@ describe("MediaBackground", () => {
render(<MediaBackground {...props} />); render(<MediaBackground {...props} />);
expect(screen.getByTestId("child-content")).toBeInTheDocument(); expect(screen.getByTestId("child-content")).toBeInTheDocument();
expect(screen.getByTestId("next-image")).toHaveAttribute("src", "/uploads/test-image.jpg"); expect(screen.getByTestId("next-image")).toHaveAttribute("src", "/files/test-image.jpg");
}); });
test("renders error message when image not found", () => { test("renders error message when image not found", () => {

View File

@@ -164,7 +164,7 @@ const nextConfig = {
}, },
{ {
key: "Content-Security-Policy", key: "Content-Security-Policy",
value: `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https://*.intercom.io https://*.intercomcdn.com https:; style-src 'self' 'unsafe-inline' https://*.intercomcdn.com https:; img-src 'self' blob: data: https://*.intercom.io https://*.intercomcdn.com data: https:; font-src 'self' data: https://*.intercomcdn.com https:; connect-src 'self' https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https:; frame-src 'self' https://*.intercom.io https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`, value: `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https://*.intercom.io https://*.intercomcdn.com https:; style-src 'self' 'unsafe-inline' https://*.intercomcdn.com https:; img-src 'self' blob: data: https://*.intercom.io https://*.intercomcdn.com https:; font-src 'self' data: https://*.intercomcdn.com https:; connect-src 'self' http://localhost:9000 https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https:; frame-src 'self' https://*.intercom.io https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`,
}, },
{ {
key: "Strict-Transport-Security", key: "Strict-Transport-Security",
@@ -452,8 +452,8 @@ const sentryOptions = {
}; };
const exportConfig = const exportConfig =
(process.env.SENTRY_DSN && process.env.NODE_ENV === "production") process.env.SENTRY_DSN && process.env.NODE_ENV === "production"
? withSentryConfig(nextConfig, sentryOptions) : ? withSentryConfig(nextConfig, sentryOptions)
nextConfig; : nextConfig;
export default exportConfig; export default exportConfig;

View File

@@ -31,6 +31,7 @@
"@formbricks/js-core": "workspace:*", "@formbricks/js-core": "workspace:*",
"@formbricks/logger": "workspace:*", "@formbricks/logger": "workspace:*",
"@formbricks/surveys": "workspace:*", "@formbricks/surveys": "workspace:*",
"@formbricks/storage": "workspace:*",
"@formbricks/types": "workspace:*", "@formbricks/types": "workspace:*",
"@fortedigital/nextjs-cache-handler": "1.2.0", "@fortedigital/nextjs-cache-handler": "1.2.0",
"@hookform/resolvers": "5.0.1", "@hookform/resolvers": "5.0.1",

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