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.
alwaysApply: false
description: >
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
and data patterns. It should be used **only when the agent explicitly requests database schema-level
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
globs: []
alwaysApply: agent-requested
---
# Formbricks Database Schema Reference

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.
# Custom local storage path for file uploads
#UPLOADS_DIR=
##############
# S3 STORAGE #
##############

View File

@@ -55,6 +55,18 @@ jobs:
--health-interval=10s
--health-timeout=5s
--health-retries=5
minio:
image: bitnami/minio:2025.7.23-debian-12-r5
env:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- 9000:9000
options: >-
--health-cmd="curl -fsS http://localhost:9000/minio/health/live || exit 1"
--health-interval=10s
--health-timeout=5s
--health-retries=20
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
@@ -92,8 +104,50 @@ jobs:
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
echo "" >> .env
echo "E2E_TESTING=1" >> .env
echo "S3_REGION=us-east-1" >> .env
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
echo "S3_ACCESS_KEY=minioadmin" >> .env
echo "S3_SECRET_KEY=minioadmin" >> .env
echo "S3_FORCE_PATH_STYLE=1" >> .env
shell: bash
- name: Install MinIO client (mc)
run: |
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
run: |
pnpm build --filter=@formbricks/web...
@@ -136,6 +190,22 @@ jobs:
sleep 10
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
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 { TUserLocale } from "@formbricks/types/user";
vi.mock("@sentry/nextjs", () => ({ captureException: vi.fn() }));
// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
default: {
@@ -158,6 +160,11 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", (
getResponsesDownloadUrlAction: vi.fn(),
}));
// Mock handleFileUpload
vi.mock("@/modules/storage/file-upload", () => ({
handleFileUpload: vi.fn(),
}));
vi.mock("@/modules/analysis/components/SingleResponseCard/actions", () => ({
deleteResponseAction: vi.fn(),
}));
@@ -192,6 +199,9 @@ vi.mock("@tolgee/react", () => ({
}),
}));
// Global mock anchor for tests
let globalMockAnchor: any;
// Define mock data for tests
const mockProps = {
data: [
@@ -232,23 +242,94 @@ beforeEach(() => {
// Reset all toast mocks before each test
vi.mocked(toast.error).mockClear();
vi.mocked(toast.success).mockClear();
vi.mocked(getResponsesDownloadUrlAction).mockClear();
// Create a mock anchor element for download tests
const mockAnchor = {
globalMockAnchor = {
href: "",
click: vi.fn(),
style: {},
download: "",
};
// Override the href setter to capture when it's set
Object.defineProperty(globalMockAnchor, "href", {
get() {
return this._href || "";
},
set(value) {
this._href = value;
},
});
// Update how we mock the document methods to avoid infinite recursion
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
if (tagName === "a") return mockAnchor as any;
if (tagName === "a") return globalMockAnchor as any;
return originalCreateElement(tagName);
});
vi.spyOn(document.body, "appendChild").mockReturnValue(null as any);
vi.spyOn(document.body, "removeChild").mockReturnValue(null as any);
// Mock File constructor to avoid arrayBuffer issues
vi.stubGlobal(
"File",
class MockFile {
name: string;
type: string;
size: number;
constructor(_chunks: any[], name: string, options: any = {}) {
this.name = name;
this.type = options.type || "";
this.size = options.size || 0;
}
arrayBuffer() {
return Promise.resolve(new ArrayBuffer(0));
}
} as any
);
// Mock atob for base64 decoding
vi.stubGlobal(
"atob",
vi.fn((_str: string) => "decoded binary string")
);
// Mock Uint8Array and Blob
vi.stubGlobal(
"Uint8Array",
class MockUint8Array extends Array {
constructor(data: any) {
super();
this.length = typeof data === "number" ? data : 0;
}
static from(source: any) {
return new MockUint8Array(source.length || 0);
}
} as any
);
vi.stubGlobal(
"Blob",
class MockBlob {
size: number;
type: string;
constructor(_parts: any[], options: any = {}) {
this.size = 0;
this.type = options.type || "";
}
} as any
);
vi.stubGlobal("URL", {
createObjectURL: vi.fn(),
revokeObjectURL: vi.fn(),
});
});
// Cleanup after each test
@@ -259,6 +340,7 @@ afterEach(() => {
}
cleanup();
vi.restoreAllMocks(); // Restore mocks after each test
vi.unstubAllGlobals(); // Restore global stubs after each test
});
describe("ResponseTable", () => {
@@ -313,52 +395,64 @@ describe("ResponseTable", () => {
test("calls downloadSelectedRows with csv format when toolbar button is clicked", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: "https://download.url/file.csv",
data: {
fileContents: "mock,csv,content",
fileName: "survey-responses.csv",
},
});
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
// Ensure URL.createObjectURL returns a deterministic URL for assertions
(URL.createObjectURL as any).mockReturnValueOnce("https://download.url/file.csv");
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey1",
format: "csv",
filterCriteria: { responseIds: [] },
});
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey1",
format: "csv",
filterCriteria: { responseIds: [] },
});
// Check if link was created and clicked
expect(document.createElement).toHaveBeenCalledWith("a");
const mockLink = document.createElement("a");
expect(mockLink.href).toBe("https://download.url/file.csv");
expect(document.body.appendChild).toHaveBeenCalled();
expect(mockLink.click).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
// Check if link was created and clicked
expect(document.createElement).toHaveBeenCalledWith("a");
expect(globalMockAnchor.href).toBe("https://download.url/file.csv");
expect(document.body.appendChild).toHaveBeenCalled();
expect(globalMockAnchor.click).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
});
});
test("calls downloadSelectedRows with xlsx format when toolbar button is clicked", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: "https://download.url/file.xlsx",
data: {
fileContents: "bW9jayB4bHN4IGNvbnRlbnQ=", // base64 encoded mock data
fileName: "survey-responses.xlsx",
},
});
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
// Ensure URL.createObjectURL returns a deterministic URL for assertions
(URL.createObjectURL as any).mockReturnValueOnce("https://download.url/file.xlsx");
const downloadXlsxButton = screen.getByTestId("download-xlsx");
await userEvent.click(downloadXlsxButton);
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey1",
format: "xlsx",
filterCriteria: { responseIds: [] },
});
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey1",
format: "xlsx",
filterCriteria: { responseIds: [] },
});
// Check if link was created and clicked
expect(document.createElement).toHaveBeenCalledWith("a");
const mockLink = document.createElement("a");
expect(mockLink.href).toBe("https://download.url/file.xlsx");
expect(document.body.appendChild).toHaveBeenCalled();
expect(mockLink.click).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
// Check if link was created and clicked
expect(document.createElement).toHaveBeenCalledWith("a");
expect(globalMockAnchor.href).toBe("https://download.url/file.xlsx");
expect(document.body.appendChild).toHaveBeenCalled();
expect(globalMockAnchor.click).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
});
});
// Test response modal

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

View File

@@ -1,8 +1,6 @@
"use server";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { WEBAPP_URL } from "@/lib/constants";
import { putFile } from "@/lib/storage/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -218,16 +216,9 @@ export const generatePersonalLinksAction = authenticatedActionClient
const csvContent = await convertToCsv(csvHeaders, csvData);
const fileName = `personal-links-${parsedInput.surveyId}-${Date.now()}.csv`;
// Store file temporarily and return download URL
const fileBuffer = Buffer.from(csvContent);
await putFile(fileName, fileBuffer, "private", parsedInput.environmentId);
const downloadUrl = `${WEBAPP_URL}/storage/${parsedInput.environmentId}/private/${fileName}`;
return {
downloadUrl,
fileName,
count: csvData.length,
csvContent,
};
});

View File

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

View File

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

View File

@@ -122,7 +122,21 @@ export const PersonalLinksTab = ({
});
if (result?.data) {
downloadFile(result.data.downloadUrl, result.data.fileName || "personal-links.csv");
const fileName = result.data.fileName || "personal-links.csv";
const file = new File([result.data.csvContent], fileName, {
type: "text/csv",
});
try {
const url = URL.createObjectURL(file);
downloadFile(url, fileName);
URL.revokeObjectURL(url);
} catch {
toast.error(t("environments.surveys.share.personal_links.error_generating_links"));
setIsGenerating(false);
return;
}
toast.success(t("environments.surveys.share.personal_links.links_generated_success_toast"), {
duration: 5000,
id: "generating-links",
@@ -134,6 +148,7 @@ export const PersonalLinksTab = ({
id: "generating-links",
});
}
setIsGenerating(false);
};

View File

@@ -1,7 +1,7 @@
"use server";
import { getOrganization } from "@/lib/organization/service";
import { getResponseDownloadUrl, getResponseFilteringValues } from "@/lib/response/service";
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
@@ -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({

View File

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

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 { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { getResponse, updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { validateFileUploads } from "@/modules/storage/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
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 { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { validateFileUploads } from "@/lib/fileValidation";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { headers } from "next/headers";
import { NextRequest } from "next/server";
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 { transformErrorToDetails } from "@/app/lib/api/validator";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFile } from "@/lib/fileValidation";
import { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey } from "@/lib/survey/service";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
import { getSignedUrlForUpload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
import { NextRequest } from "next/server";
import { ZUploadFileRequest } from "@formbricks/types/storage";
import { uploadPrivateFile } from "./lib/uploadPrivateFile";
import { logger } from "@formbricks/logger";
import { TUploadPrivateFileRequest, ZUploadPrivateFileRequest } from "@formbricks/types/storage";
interface Context {
params: Promise<{
@@ -25,46 +28,46 @@ export const OPTIONS = async (): Promise<Response> => {
);
};
// api endpoint for uploading private files
// api endpoint for getting a s3 signed url for uploading private files
// uploaded files will be private, only the user who has access to the environment can access the file
// uploading private files requires no authentication
// use this to let users upload files to a survey for example
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
// use this to let users upload files to a file upload question response for example
export const POST = withV1ApiWrapper({
handler: async ({ req, props }: { req: NextRequest; props: Context }) => {
const params = await props.params;
const environmentId = params.environmentId;
const { environmentId } = params;
let jsonInput: TUploadPrivateFileRequest;
const jsonInput = await req.json();
const inputValidation = ZUploadFileRequest.safeParse({
try {
jsonInput = await req.json();
} catch (error) {
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const parsedInputResult = ZUploadPrivateFileRequest.safeParse({
...jsonInput,
environmentId,
});
if (!inputValidation.success) {
if (!parsedInputResult.success) {
const errorDetails = transformErrorToDetails(parsedInputResult.error);
logger.error({ error: errorDetails }, "Fields are missing or incorrectly formatted");
return {
response: responses.badRequestResponse(
"Invalid request",
transformErrorToDetails(inputValidation.error),
"Fields are missing or incorrectly formatted",
errorDetails,
true
),
};
}
const { fileName, fileType, surveyId } = inputValidation.data;
// Perform server-side file validation
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
return {
response: responses.badRequestResponse(
fileValidation.error ?? "Invalid file",
{ fileName, fileType },
true
),
};
}
const { fileName, fileType, surveyId } = parsedInputResult.data;
const [survey, organization] = await Promise.all([
getSurvey(surveyId),
@@ -83,10 +86,40 @@ export const POST = withV1ApiWrapper({
};
}
if (survey.environmentId !== environmentId) {
return {
response: responses.badRequestResponse(
"Survey does not belong to the environment",
{ surveyId, environmentId },
true
),
};
}
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.billing.plan);
const maxFileUploadSize = isBiggerFileUploadAllowed
? MAX_FILE_UPLOAD_SIZES.big
: MAX_FILE_UPLOAD_SIZES.standard;
const signedUrlResponse = await getSignedUrlForUpload(
fileName,
environmentId,
fileType,
"private",
maxFileUploadSize
);
if (!signedUrlResponse.ok) {
logger.error({ error: signedUrlResponse.error }, "Error getting signed url for upload");
const errorResponse = getErrorResponseFromStorageError(signedUrlResponse.error, { fileName });
return {
response: errorResponse,
};
}
return {
response: await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed),
response: responses.successResponse(signedUrlResponse.data),
};
},
customRateLimitConfig: rateLimitConfigs.storage.upload,
});

View File

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

View File

@@ -1,10 +1,10 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation";
import { getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { 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 { describe, expect, test, vi } from "vitest";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { checkAuth, checkForRequiredFields } from "./utils";
import { checkAuth } from "./utils";
// Create mock response objects
const mockBadRequestResponse = new Response("Bad Request", { status: 400 });
@@ -27,49 +27,6 @@ vi.mock("@/app/lib/api/response", () => ({
},
}));
describe("checkForRequiredFields", () => {
test("should return undefined when all required fields are present", () => {
const result = checkForRequiredFields("env-123", "image/png", "test-file.png");
expect(result).toBeUndefined();
});
test("should return bad request response when environmentId is missing", () => {
const result = checkForRequiredFields("", "image/png", "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when fileType is missing", () => {
const result = checkForRequiredFields("env-123", "", "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when encodedFileName is missing", () => {
const result = checkForRequiredFields("env-123", "image/png", "");
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when environmentId is undefined", () => {
const result = checkForRequiredFields(undefined as any, "image/png", "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when fileType is undefined", () => {
const result = checkForRequiredFields("env-123", undefined as any, "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when encodedFileName is undefined", () => {
const result = checkForRequiredFields("env-123", "image/png", undefined as any);
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
expect(result).toBe(mockBadRequestResponse);
});
});
describe("checkAuth", () => {
const environmentId = "env-123";

View File

@@ -3,24 +3,6 @@ import { TApiV1Authentication } from "@/app/lib/api/with-api-logging";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
export const checkForRequiredFields = (
environmentId: string,
fileType: string,
encodedFileName: string
): Response | undefined => {
if (!environmentId) {
return responses.badRequestResponse("environmentId is required");
}
if (!fileType) {
return responses.badRequestResponse("contentType is required");
}
if (!encodedFileName) {
return responses.badRequestResponse("fileName is required");
}
};
export const checkAuth = async (authentication: TApiV1Authentication, environmentId: string) => {
if (!authentication) {
return responses.notAuthenticatedResponse();

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 { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiV1Authentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { validateFile } from "@/lib/fileValidation";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getSignedUrlForUpload } from "@/modules/storage/service";
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
import { TUploadPublicFileRequest, ZUploadPublicFileRequest } from "@formbricks/types/storage";
// api endpoint for uploading public files
// api endpoint for getting a signed url for uploading a public file
// uploaded files will be public, anyone can access the file
// uploading public files requires authentication
// use this to upload files for a specific resource, e.g. a user profile picture or a survey
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
// use this to get a signed url for uploading a public file for a specific resource, e.g. a survey's background image
export const POST = withV1ApiWrapper({
handler: async ({ req, authentication }: { req: NextRequest; authentication: TApiV1Authentication }) => {
let storageInput;
let storageInput: TUploadPublicFileRequest;
try {
storageInput = await req.json();
@@ -25,15 +27,24 @@ export const POST = withV1ApiWrapper({
};
}
const { fileName, fileType, environmentId, allowedFileExtensions } = storageInput;
const parsedInputResult = ZUploadPublicFileRequest.safeParse(storageInput);
if (!parsedInputResult.success) {
const errorDetails = transformErrorToDetails(parsedInputResult.error);
logger.error({ error: errorDetails }, "Fields are missing or incorrectly formatted");
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, fileName);
if (requiredFieldResponse) {
return {
response: requiredFieldResponse,
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
errorDetails,
true
),
};
}
const { fileName, fileType, environmentId } = parsedInputResult.data;
const authResponse = await checkAuth(authentication, environmentId);
if (authResponse) {
return {
@@ -41,28 +52,19 @@ export const POST = withV1ApiWrapper({
};
}
// Perform server-side file validation first to block dangerous file types
const fileValidation = validateFile(fileName, fileType);
if (!fileValidation.valid) {
const signedUrlResponse = await getSignedUrlForUpload(fileName, environmentId, fileType, "public");
if (!signedUrlResponse.ok) {
logger.error({ error: signedUrlResponse.error }, "Error getting signed url for upload");
const errorResponse = getErrorResponseFromStorageError(signedUrlResponse.error, { fileName });
return {
response: responses.badRequestResponse(fileValidation.error ?? "Invalid file type"),
response: errorResponse,
};
}
// Also perform client-specified allowed file extensions validation if provided
if (allowedFileExtensions?.length) {
const fileExtension = fileName.split(".").pop()?.toLowerCase();
if (!fileExtension || !allowedFileExtensions.includes(fileExtension)) {
return {
response: responses.badRequestResponse(
`File extension is not allowed, allowed extensions are: ${allowedFileExtensions.join(", ")}`
),
};
}
}
return {
response: await getSignedUrlForPublicFile(fileName, environmentId, fileType),
response: responses.successResponse(signedUrlResponse.data),
};
},
customRateLimitConfig: rateLimitConfigs.storage.upload,
});

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

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 { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { handleDeleteFile } from "@/app/storage/[environmentId]/[accessType]/[fileName]/lib/delete-file";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { 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 { type NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZStorageRetrievalParams } from "@formbricks/types/storage";
import { getFile } from "./lib/get-file";
import { TAccessType, ZDeleteFileRequest, ZDownloadFileRequest } from "@formbricks/types/storage";
import { logFileDeletion } from "./lib/audit-logs";
export const GET = async (
request: NextRequest,
props: { params: Promise<{ environmentId: string; accessType: string; fileName: string }> }
props: { params: Promise<{ environmentId: string; accessType: TAccessType; fileName: string }> }
): Promise<Response> => {
const params = await props.params;
const paramValidation = ZStorageRetrievalParams.safeParse(params);
const paramValidation = ZDownloadFileRequest.safeParse(params);
if (!paramValidation.success) {
return responses.badRequestResponse(
@@ -28,36 +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") {
return await getFile(environmentId, accessType, fileName);
}
if (!session?.user) {
// 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) {
// check for api key auth
const res = await authenticateRequest(request);
if (!res) {
return responses.notAuthenticatedResponse();
if (!isUserAuthorized) {
return responses.unauthorizedResponse();
}
}
return await getFile(environmentId, accessType, fileName);
}
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
const signedUrlResult = await getSignedUrlForDownload(fileName, environmentId, accessType);
if (!isUserAuthorized) {
return responses.unauthorizedResponse();
if (!signedUrlResult.ok) {
const errorResponse = getErrorResponseFromStorageError(signedUrlResult.error, { fileName });
return errorResponse;
}
return await getFile(environmentId, accessType, fileName);
return new Response(null, {
status: 302,
headers: {
Location: signedUrlResult.data,
"Cache-Control":
accessType === "private"
? "no-store, no-cache, must-revalidate"
: "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
},
});
};
export const DELETE = async (
@@ -65,157 +79,101 @@ export const DELETE = async (
props: { params: Promise<{ environmentId: string; accessType: string; fileName: string }> }
): Promise<Response> => {
const params = await props.params;
const getOrgId = async (environmentId: string): Promise<string> => {
try {
return await getOrganizationIdFromEnvironmentId(environmentId);
} catch (error) {
logger.error("Failed to get organization ID for environment", { error });
return UNKNOWN_DATA;
}
};
const logFileDeletion = async ({
accessType,
userId,
status = "failure",
failureReason,
oldObject,
}: {
accessType?: string;
userId?: string;
status?: TAuditStatus;
failureReason?: string;
oldObject?: Record<string, unknown>;
}) => {
try {
const organizationId = await getOrgId(environmentId);
await queueAuditEvent({
action: "deleted",
targetType: "file",
userId: userId || UNKNOWN_DATA, // NOSONAR // We want to check for empty user IDs too
userType: "user",
targetId: `${environmentId}:${accessType}`, // Generic target identifier
organizationId,
status,
newObject: {
environmentId,
accessType,
...(failureReason && { failureReason }),
},
oldObject,
apiUrl: request.url,
});
} catch (auditError) {
logger.error("Failed to log file deletion audit event:", auditError);
}
};
// Validation
if (!params.fileName) {
await logFileDeletion({
failureReason: "fileName parameter missing",
});
return responses.badRequestResponse("Fields are missing or incorrectly formatted", {
fileName: "fileName is required",
});
}
const { environmentId, accessType, fileName } = params;
// Security check: If fileName contains the same properties from the route, ensure they match
// This is to prevent a user from deleting a file from a different environment
const [fileEnvironmentId, fileAccessType, file] = fileName.split("/");
if (fileEnvironmentId !== environmentId) {
await logFileDeletion({
failureReason: "Environment ID mismatch between route and fileName",
accessType,
});
return responses.badRequestResponse("Environment ID mismatch", {
message: "The environment ID in the fileName does not match the route environment ID",
});
}
if (fileAccessType !== accessType) {
await logFileDeletion({
failureReason: "Access type mismatch between route and fileName",
accessType,
});
return responses.badRequestResponse("Access type mismatch", {
message: "The access type in the fileName does not match the route access type",
});
}
const paramValidation = ZStorageRetrievalParams.safeParse({ fileName: file, environmentId, accessType });
const paramValidation = ZDeleteFileRequest.safeParse(params);
if (!paramValidation.success) {
const errorDetails = transformErrorToDetails(paramValidation.error);
await logFileDeletion({
failureReason: "Parameter validation failed",
accessType,
environmentId: params.environmentId,
apiUrl: request.url,
});
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(paramValidation.error),
true
);
return responses.badRequestResponse("Fields are missing or incorrectly formatted", errorDetails, true);
}
const {
environmentId: validEnvId,
accessType: validAccessType,
fileName: validFileName,
} = paramValidation.data;
const { environmentId, accessType, fileName } = paramValidation.data;
// Authentication
const session = await getServerSession(authOptions);
if (!session?.user) {
await logFileDeletion({
failureReason: "User not authenticated",
accessType: validAccessType,
});
return responses.notAuthenticatedResponse();
}
// check for api key auth
const auth = await authenticateRequest(request);
// Authorization
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, validEnvId);
if (!isUserAuthorized) {
await logFileDeletion({
failureReason: "User not authorized to access environment",
accessType: validAccessType,
userId: session.user.id,
});
return responses.unauthorizedResponse();
}
try {
const deleteResult = await handleDeleteFile(validEnvId, validAccessType, validFileName);
const isSuccess = deleteResult.status === 200;
let failureReason = "File deletion failed";
if (!isSuccess) {
try {
const responseBody = await deleteResult.json();
failureReason = responseBody.message || failureReason; // NOSONAR // We want to check for empty messages too
} catch (error) {
logger.error("Failed to parse file delete error response body", { error });
}
if (!auth) {
await logFileDeletion({
failureReason: "User not authenticated",
accessType,
environmentId,
apiUrl: request.url,
});
return responses.notAuthenticatedResponse();
}
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({
status: isSuccess ? "success" : "failure",
failureReason: isSuccess ? undefined : failureReason,
accessType: validAccessType,
userId: session.user.id,
failureReason: deleteResult.error.code,
accessType,
userId: session?.user?.id,
environmentId,
apiUrl: request.url,
});
return deleteResult;
} catch (error) {
await logFileDeletion({
failureReason: error instanceof Error ? error.message : "Unexpected error during file deletion",
accessType: validAccessType,
userId: session.user.id,
});
throw error;
const errorResponse = getErrorResponseFromStorageError(deleteResult.error, { fileName });
return errorResponse;
}
await logFileDeletion({
status: "success",
accessType,
userId: session?.user?.id,
environmentId,
apiUrl: request.url,
});
return responses.successResponse("File deleted successfully");
};

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_BUCKET_NAME = env.S3_BUCKET_NAME;
export const S3_FORCE_PATH_STYLE = env.S3_FORCE_PATH_STYLE === "1";
export const UPLOADS_DIR = env.UPLOADS_DIR ?? "./uploads";
export const MAX_SIZES = {
export const MAX_FILE_UPLOAD_SIZES = {
standard: 1024 * 1024 * 10, // 10MB
big: 1024 * 1024 * 1024, // 1GB
} as const;
// Function to check if the necessary S3 configuration is set up
export const isS3Configured = () => {
// This function checks if the S3 bucket name environment variable is defined.
// The AWS SDK automatically resolves credentials through a chain,
// so we do not need to explicitly check for AWS credentials like access key, secret key, or region.
return !!S3_BUCKET_NAME;
};
// Colors for Survey Bg
export const SURVEY_BG_COLORS = [
"#FFFFFF",

View File

@@ -1,12 +1,6 @@
import { createCipheriv, randomBytes } from "crypto";
import { describe, expect, test, vi } from "vitest";
import {
generateLocalSignedUrl,
getHash,
symmetricDecrypt,
symmetricEncrypt,
validateLocalSignedUrl,
} from "./crypto";
import { getHash, symmetricDecrypt, symmetricEncrypt } from "./crypto";
vi.mock("./constants", () => ({ ENCRYPTION_KEY: "0".repeat(32) }));
@@ -44,16 +38,4 @@ describe("crypto", () => {
expect(typeof h).toBe("string");
expect(h.length).toBeGreaterThan(0);
});
test("signed URL generation & validation", () => {
const { uuid, timestamp, signature } = generateLocalSignedUrl("f", "e", "t");
expect(uuid).toHaveLength(32);
expect(typeof timestamp).toBe("number");
expect(typeof signature).toBe("string");
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp, signature, key)).toBe(true);
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp, "bad", key)).toBe(false);
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp - 1000 * 60 * 6, signature, key)).toBe(
false
);
});
});

View File

@@ -1,4 +1,4 @@
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "crypto";
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
import { logger } from "@formbricks/logger";
import { ENCRYPTION_KEY } from "./constants";
@@ -92,39 +92,3 @@ export function symmetricDecrypt(payload: string, key: string): string {
}
export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex");
export const generateLocalSignedUrl = (
fileName: string,
environmentId: string,
fileType: string
): { signature: string; uuid: string; timestamp: number } => {
const uuid = randomBytes(16).toString("hex");
const timestamp = Date.now();
const data = `${uuid}:${fileName}:${environmentId}:${fileType}:${timestamp}`;
const signature = createHmac("sha256", ENCRYPTION_KEY).update(data).digest("hex");
return { signature, uuid, timestamp };
};
export const validateLocalSignedUrl = (
uuid: string,
fileName: string,
environmentId: string,
fileType: string,
timestamp: number,
signature: string,
secret: string
): boolean => {
const data = `${uuid}:${fileName}:${environmentId}:${fileType}:${timestamp}`;
const expectedSignature = createHmac("sha256", secret).update(data).digest("hex");
if (expectedSignature !== signature) {
return false;
}
// valid for 5 minutes
if (Date.now() - timestamp > 1000 * 60 * 5) {
return false;
}
return true;
};

View File

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

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

View File

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

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 { InvalidInputError } from "@formbricks/types/errors";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";

View File

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

View File

@@ -15,12 +15,16 @@ export const convertToCsv = async (fields: string[], jsonData: Record<string, st
logger.error(err, "Failed to convert to CSV");
throw new Error("Failed to convert to CSV");
}
return csv;
};
export const convertToXlsxBuffer = (fields: string[], jsonData: Record<string, string | number>[]) => {
export const convertToXlsxBuffer = (
fields: string[],
jsonData: Record<string, string | number>[]
): Buffer => {
const wb = xlsx.utils.book_new();
const ws = xlsx.utils.json_to_sheet(jsonData, { header: fields });
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
return xlsx.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer;
return xlsx.write(wb, { type: "buffer", bookType: "xlsx" });
};

View File

@@ -125,6 +125,7 @@
"add_filter": "Filter hinzufügen",
"add_logo": "Logo hinzufügen",
"add_member": "Mitglied hinzufügen",
"add_new_project": "Neues Projekt hinzufügen",
"add_project": "Projekt hinzufügen",
"add_to_team": "Zum Team hinzufügen",
"all": "Alle",
@@ -149,6 +150,9 @@
"cancel": "Abbrechen",
"centered_modal": "Zentriertes Modalfenster",
"choices": "Entscheidungen",
"choose_environment": "Umgebung auswählen",
"choose_organization": "Organisation auswählen",
"choose_project": "Projekt wählen",
"clear_all": "Alles löschen",
"clear_filters": "Filter löschen",
"clear_selection": "Auswahl aufheben",
@@ -177,7 +181,6 @@
"created_at": "Erstellt am",
"created_by": "Erstellt von",
"customer_success": "Kundenerfolg",
"danger_zone": "Gefahrenzone",
"dark_overlay": "Dunkle Überlagerung",
"date": "Datum",
"default": "Standard",
@@ -201,6 +204,10 @@
"environment_not_found": "Umgebung nicht gefunden",
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
"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",
"finish": "Fertigstellen",
"follow_these": "Folge diesen",
@@ -232,11 +239,9 @@
"label": "Bezeichnung",
"language": "Sprache",
"learn_more": "Mehr erfahren",
"license": "Lizenz",
"light_overlay": "Helle Überlagerung",
"limits_reached": "Limits erreicht",
"link": "Link",
"link_and_email": "Link & E-Mail",
"link_survey": "Link-Umfrage",
"link_surveys": "Umfragen verknüpfen",
"load_more": "Mehr laden",
@@ -283,6 +288,7 @@
"organization": "Organisation",
"organization_id": "Organisations-ID",
"organization_not_found": "Organisation nicht gefunden",
"organization_settings": "Organisationseinstellungen",
"organization_teams_not_found": "Organisations-Teams nicht gefunden",
"other": "Andere",
"others": "Andere",
@@ -306,7 +312,7 @@
"product_manager": "Produktmanager",
"profile": "Profil",
"profile_id": "Profil-ID",
"project_configuration": "Projektkonfiguration",
"project_configuration": "Projekteinstellungen",
"project_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
"project_id": "Projekt-ID",
"project_name": "Projektname",
@@ -378,7 +384,6 @@
"survey_scheduled": "Umfrage geplant.",
"survey_type": "Umfragetyp",
"surveys": "Umfragen",
"switch_organization": "Organisation wechseln",
"switch_to": "Wechseln zu {environment}",
"table_items_deleted_successfully": "{type}s erfolgreich gelöscht",
"table_settings": "Tabelleinstellungen",
@@ -576,8 +581,6 @@
"contacts_table_refresh": "Kontakte aktualisieren",
"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.",
"first_name": "Vorname",
"last_name": "Nachname",
"no_responses_found": "Keine Antworten gefunden",
"not_provided": "Nicht angegeben",
"search_contact": "Kontakt suchen",
@@ -989,7 +992,6 @@
"free": "Kostenlos",
"free_description": "Unbegrenzte Umfragen, Teammitglieder und mehr.",
"get_2_months_free": "2 Monate gratis",
"get_in_touch": "Kontaktiere uns",
"hosted_in_frankfurt": "Gehostet in Frankfurt",
"ios_android_sdks": "iOS & Android SDK für mobile Umfragen",
"link_surveys": "Umfragen verlinken (teilbar)",

View File

@@ -125,6 +125,7 @@
"add_filter": "Add filter",
"add_logo": "Add logo",
"add_member": "Add member",
"add_new_project": "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 environment",
"choose_organization": "Choose organization",
"choose_project": "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": "Danger Zone",
"dark_overlay": "Dark overlay",
"date": "Date",
"default": "Default",
@@ -201,6 +204,10 @@
"environment_not_found": "Environment not found",
"environment_notice": "You're currently in the {environment} environment.",
"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",
"finish": "Finish",
"follow_these": "Follow these",
@@ -232,11 +239,9 @@
"label": "Label",
"language": "Language",
"learn_more": "Learn more",
"license": "License",
"light_overlay": "Light overlay",
"limits_reached": "Limits Reached",
"link": "Link",
"link_and_email": "Link & Email",
"link_survey": "Link Survey",
"link_surveys": "Link Surveys",
"load_more": "Load more",
@@ -283,6 +288,7 @@
"organization": "Organization",
"organization_id": "Organization ID",
"organization_not_found": "Organization not found",
"organization_settings": "Organization settings",
"organization_teams_not_found": "Organization teams not found",
"other": "Other",
"others": "Others",
@@ -306,7 +312,7 @@
"product_manager": "Product Manager",
"profile": "Profile",
"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_id": "Project ID",
"project_name": "Project Name",
@@ -378,7 +384,6 @@
"survey_scheduled": "Survey scheduled.",
"survey_type": "Survey Type",
"surveys": "Surveys",
"switch_organization": "Switch organization",
"switch_to": "Switch to {environment}",
"table_items_deleted_successfully": "{type}s deleted successfully",
"table_settings": "Table settings",
@@ -576,8 +581,6 @@
"contacts_table_refresh": "Refresh contacts",
"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.",
"first_name": "First Name",
"last_name": "Last Name",
"no_responses_found": "No responses found",
"not_provided": "Not provided",
"search_contact": "Search contact",
@@ -767,7 +770,7 @@
"check_out_the_docs": "Check out the docs.",
"dive_into_the_docs": "Dive into the docs.",
"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_with_environment_id": "Used to identify the correct environment: {environmentId} is yours.",
"formbricks_sdk": "Formbricks SDK",
@@ -989,7 +992,6 @@
"free": "Free",
"free_description": "Unlimited Surveys, Team Members, and more.",
"get_2_months_free": "Get 2 months free",
"get_in_touch": "Get in touch",
"hosted_in_frankfurt": "Hosted in Frankfurt",
"ios_android_sdks": "iOS & Android SDK for mobile surveys",
"link_surveys": "Link Surveys (Shareable)",

View File

@@ -125,6 +125,7 @@
"add_filter": "Ajouter un filtre",
"add_logo": "Ajouter un logo",
"add_member": "Ajouter un membre",
"add_new_project": "Ajouter un nouveau projet",
"add_project": "Ajouter un projet",
"add_to_team": "Ajouter à l'équipe",
"all": "Tout",
@@ -149,6 +150,9 @@
"cancel": "Annuler",
"centered_modal": "Modal centré",
"choices": "Choix",
"choose_environment": "Choisir l'environnement",
"choose_organization": "Choisir l'organisation",
"choose_project": "Choisir projet",
"clear_all": "Tout effacer",
"clear_filters": "Effacer les filtres",
"clear_selection": "Effacer la sélection",
@@ -177,7 +181,6 @@
"created_at": "Créé le",
"created_by": "Créé par",
"customer_success": "Succès Client",
"danger_zone": "Zone de danger",
"dark_overlay": "Superposition sombre",
"date": "Date",
"default": "Par défaut",
@@ -201,6 +204,10 @@
"environment_not_found": "Environnement non trouvé",
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
"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",
"finish": "Terminer",
"follow_these": "Suivez ceci",
@@ -232,11 +239,9 @@
"label": "Étiquette",
"language": "Langue",
"learn_more": "En savoir plus",
"license": "Licence",
"light_overlay": "Superposition légère",
"limits_reached": "Limites atteints",
"link": "Lien",
"link_and_email": "Liens et e-mail",
"link_survey": "Enquête de lien",
"link_surveys": "Sondages de lien",
"load_more": "Charger plus",
@@ -283,6 +288,7 @@
"organization": "Organisation",
"organization_id": "ID de l'organisation",
"organization_not_found": "Organisation non trouvée",
"organization_settings": "Paramètres de l'organisation",
"organization_teams_not_found": "Équipes d'organisation non trouvées",
"other": "Autre",
"others": "Autres",
@@ -378,7 +384,6 @@
"survey_scheduled": "Sondage programmé.",
"survey_type": "Type de sondage",
"surveys": "Enquêtes",
"switch_organization": "Changer d'organisation",
"switch_to": "Passer à {environment}",
"table_items_deleted_successfully": "{type}s supprimés avec succès",
"table_settings": "Réglages de table",
@@ -576,8 +581,6 @@
"contacts_table_refresh": "Rafraîchir les contacts",
"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.",
"first_name": "Prénom",
"last_name": "Nom de famille",
"no_responses_found": "Aucune réponse trouvée",
"not_provided": "Non fourni",
"search_contact": "Rechercher un contact",
@@ -989,7 +992,6 @@
"free": "Gratuit",
"free_description": "Sondages illimités, membres d'équipe, et plus encore.",
"get_2_months_free": "Obtenez 2 mois gratuits",
"get_in_touch": "Prenez contact",
"hosted_in_frankfurt": "Hébergé à Francfort",
"ios_android_sdks": "SDK iOS et Android pour les sondages mobiles",
"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_logo": "Adicionar logo",
"add_member": "Adicionar membro",
"add_new_project": "Adicionar novo projeto",
"add_project": "Adicionar projeto",
"add_to_team": "Adicionar à equipe",
"all": "Todos",
@@ -149,6 +150,9 @@
"cancel": "Cancelar",
"centered_modal": "Modal Centralizado",
"choices": "Escolhas",
"choose_environment": "Escolher ambiente",
"choose_organization": "Escolher organização",
"choose_project": "Escolher projeto",
"clear_all": "Limpar tudo",
"clear_filters": "Limpar filtros",
"clear_selection": "Limpar seleção",
@@ -177,7 +181,6 @@
"created_at": "Data de criação",
"created_by": "Criado por",
"customer_success": "Sucesso do Cliente",
"danger_zone": "Zona de Perigo",
"dark_overlay": "sobreposição escura",
"date": "Encontro",
"default": "Padrão",
@@ -201,6 +204,10 @@
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Você está atualmente no ambiente {environment}.",
"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",
"finish": "Terminar",
"follow_these": "Siga esses",
@@ -232,11 +239,9 @@
"label": "Etiqueta",
"language": "Língua",
"learn_more": "Saiba mais",
"license": "Licença",
"light_overlay": "sobreposição leve",
"limits_reached": "Limites Atingidos",
"link": "link",
"link_and_email": "Link & E-mail",
"link_survey": "Pesquisa de Link",
"link_surveys": "Link de Pesquisas",
"load_more": "Carregar mais",
@@ -283,6 +288,7 @@
"organization": "organização",
"organization_id": "ID da Organização",
"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",
"other": "outro",
"others": "Outros",
@@ -378,7 +384,6 @@
"survey_scheduled": "Pesquisa agendada.",
"survey_type": "Tipo de Pesquisa",
"surveys": "Pesquisas",
"switch_organization": "Mudar organização",
"switch_to": "Mudar para {environment}",
"table_items_deleted_successfully": "{type}s deletados com sucesso",
"table_settings": "Arrumação da mesa",
@@ -576,8 +581,6 @@
"contacts_table_refresh": "Atualizar contatos",
"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.",
"first_name": "Primeiro Nome",
"last_name": "Sobrenome",
"no_responses_found": "Nenhuma resposta encontrada",
"not_provided": "Não fornecido",
"search_contact": "Buscar contato",
@@ -989,7 +992,6 @@
"free": "grátis",
"free_description": "Pesquisas ilimitadas, membros da equipe e mais.",
"get_2_months_free": "Ganhe 2 meses grátis",
"get_in_touch": "Entre em contato",
"hosted_in_frankfurt": "Hospedado em Frankfurt",
"ios_android_sdks": "SDK para iOS e Android para pesquisas móveis",
"link_surveys": "Link de Pesquisas (Compartilhável)",

View File

@@ -125,6 +125,7 @@
"add_filter": "Adicionar filtro",
"add_logo": "Adicionar logótipo",
"add_member": "Adicionar membro",
"add_new_project": "Adicionar novo projeto",
"add_project": "Adicionar projeto",
"add_to_team": "Adicionar à equipa",
"all": "Todos",
@@ -149,6 +150,9 @@
"cancel": "Cancelar",
"centered_modal": "Modal Centralizado",
"choices": "Escolhas",
"choose_environment": "Escolha o ambiente",
"choose_organization": "Escolher organização",
"choose_project": "Escolher projeto",
"clear_all": "Limpar tudo",
"clear_filters": "Limpar filtros",
"clear_selection": "Limpar seleção",
@@ -177,7 +181,6 @@
"created_at": "Criado em",
"created_by": "Criado por",
"customer_success": "Sucesso do Cliente",
"danger_zone": "Zona de Perigo",
"dark_overlay": "Sobreposição escura",
"date": "Data",
"default": "Padrão",
@@ -201,6 +204,10 @@
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Está atualmente no ambiente {environment}.",
"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",
"finish": "Concluir",
"follow_these": "Siga estes",
@@ -232,11 +239,9 @@
"label": "Etiqueta",
"language": "Idioma",
"learn_more": "Saiba mais",
"license": "Licença",
"light_overlay": "Sobreposição leve",
"limits_reached": "Limites Atingidos",
"link": "Link",
"link_and_email": "Link e Email",
"link_survey": "Ligar Inquérito",
"link_surveys": "Ligar Inquéritos",
"load_more": "Carregar mais",
@@ -283,6 +288,7 @@
"organization": "Organização",
"organization_id": "ID da Organização",
"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",
"other": "Outro",
"others": "Outros",
@@ -378,7 +384,6 @@
"survey_scheduled": "Inquérito agendado.",
"survey_type": "Tipo de Inquérito",
"surveys": "Inquéritos",
"switch_organization": "Mudar de organização",
"switch_to": "Mudar para {environment}",
"table_items_deleted_successfully": "{type}s eliminados com sucesso",
"table_settings": "Configurações da tabela",
@@ -576,8 +581,6 @@
"contacts_table_refresh": "Atualizar contactos",
"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.",
"first_name": "Primeiro Nome",
"last_name": "Apelido",
"no_responses_found": "Nenhuma resposta encontrada",
"not_provided": "Não fornecido",
"search_contact": "Procurar contacto",
@@ -767,7 +770,7 @@
"check_out_the_docs": "Consulte a documentação.",
"dive_into_the_docs": "Mergulhe na documentação.",
"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_with_environment_id": "Usado para identificar o ambiente correto: {environmentId} é o seu.",
"formbricks_sdk": "SDK Formbricks",
@@ -989,7 +992,6 @@
"free": "Grátis",
"free_description": "Inquéritos ilimitados, membros da equipa e mais.",
"get_2_months_free": "Obtenha 2 meses grátis",
"get_in_touch": "Entre em contacto",
"hosted_in_frankfurt": "Hospedado em Frankfurt",
"ios_android_sdks": "SDK iOS e Android para inquéritos móveis",
"link_surveys": "Ligar Inquéritos (Partilhável)",

View File

@@ -1,7 +1,7 @@
{
"auth": {
"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_google": "Continuă cu Google",
"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_description": "Codul de invitație nu poate fi găsit sau a fost deja utilizat.",
"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."
},
"last_used": "Ultima utilizare",
@@ -65,7 +65,7 @@
"new_to_formbricks": "Nou în Formbricks?",
"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": {
"captcha_failed": "Captcha eșuat",
"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_minimum_8_and_maximum_128_characters": "Minim 8 & Maxim 128 caractere",
"password_validation_uppercase_and_lowercase": "Amestec de majuscule și minuscule",
"please_verify_captcha": "Vă rugăm să verificați reCAPTCHA",
"privacy_policy": "Politica de Confidențialitate",
"terms_of_service": "Termeni de Serviciu",
"please_verify_captcha": "Vă rugăm să verificați CAPTCHA",
"privacy_policy": "Politica de confidențialitate",
"terms_of_service": "Termeni de utilizare a serviciului",
"title": "Creați-vă contul Formbricks"
},
"signup_without_verification_success": {
"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."
},
"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_free_and_open_source": "Gratuit și open-source",
"testimonial_no_credit_card_required": "Nu este necesar niciun card de credit",
@@ -125,6 +125,7 @@
"add_filter": "Adăugați filtru",
"add_logo": "Adaugă logo",
"add_member": "Adaugă membru",
"add_new_project": "Adaugă proiect nou",
"add_project": "Adaugă proiect",
"add_to_team": "Adaugă la echipă",
"all": "Toate",
@@ -137,7 +138,7 @@
"anonymous": "Anonim",
"api_keys": "Chei API",
"app": "Aplicație",
"app_survey": "Sondaj Aplicație",
"app_survey": "Sondaj aplicație",
"apply_filters": "Aplică filtre",
"are_you_sure": "Ești sigur?",
"attributes": "Atribute",
@@ -149,6 +150,9 @@
"cancel": "Anulare",
"centered_modal": "Modală centralizată",
"choices": "Alegeri",
"choose_environment": "Alege mediul",
"choose_organization": "Alege organizația",
"choose_project": "Alege proiectul",
"clear_all": "Șterge tot",
"clear_filters": "Curăță filtrele",
"clear_selection": "Șterge selecția",
@@ -177,7 +181,6 @@
"created_at": "Creat la",
"created_by": "Creat de",
"customer_success": "Succesul Clientului",
"danger_zone": "Zonă periculoasă",
"dark_overlay": "Suprapunere întunecată",
"date": "Dată",
"default": "Implicit",
@@ -201,6 +204,10 @@
"environment_not_found": "Mediul nu a fost găsit",
"environment_notice": "Te afli în prezent în mediul {environment}",
"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",
"finish": "Finalizează",
"follow_these": "Urmați acestea",
@@ -232,11 +239,9 @@
"label": "Etichetă",
"language": "Limba",
"learn_more": "Află mai multe",
"license": "Licență",
"light_overlay": "Suprapunere ușoară",
"limits_reached": "Limite atinse",
"link": "Legătura",
"link_and_email": "Link & email",
"link_survey": "Conectează chestionarul",
"link_surveys": "Conectează chestionarele",
"load_more": "Încarcă mai multe",
@@ -282,13 +287,14 @@
"or": "sau",
"organization": "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",
"other": "Altele",
"others": "Altele",
"overview": "Prezentare generală",
"password": "Parolă",
"paused": "Pauzat",
"paused": "Pauză",
"pending_downgrade": "Reducere în aşteptare",
"people_manager": "Manager de persoane",
"person": "Persoană",
@@ -306,7 +312,7 @@
"product_manager": "Manager de Produs",
"profile": "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_id": "ID proiect",
"project_name": "Nume proiect",
@@ -324,7 +330,7 @@
"report_survey": "Raportează chestionarul",
"request_pricing": "Solicită Prețuri",
"request_trial_license": "Solicitați o licență de încercare",
"reset_to_default": "Revină la implicit",
"reset_to_default": "Revino la implicit",
"response": "Răspuns",
"responses": "Răspunsuri",
"restart": "Repornește",
@@ -354,7 +360,7 @@
"share_feedback": "Împărtășește feedback",
"show": "Afișează",
"show_response_count": "Afișează numărul de răspunsuri",
"shown": "Arătat",
"shown": "Afișat",
"size": "Mărime",
"skipped": "Sărit",
"skips": "Salturi",
@@ -362,7 +368,7 @@
"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.",
"sort_by": "Sortare după",
"start_free_trial": "Începe Perioada de Testare Gratuită",
"start_free_trial": "Începe perioada de testare gratuită",
"status": "Stare",
"step_by_step_manual": "Manual pas cu pas",
"styling": "Stilizare",
@@ -378,7 +384,6 @@
"survey_scheduled": "Chestionar programat.",
"survey_type": "Tip Chestionar",
"surveys": "Sondaje",
"switch_organization": "Comută organizația",
"switch_to": "Comută la {environment}",
"table_items_deleted_successfully": "\"{type} șterse cu succes\"",
"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.",
"email_customization_preview_email_heading": "Salut {userName}",
"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_2": "Echipa 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_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_heading": "Previzualizare Incorporare Email",
"embed_survey_preview_email_subject": "Previzualizare Chestionar Email Formbricks",
"embed_survey_preview_email_heading": "Previzualizare încorporare email",
"embed_survey_preview_email_subject": "Previzualizare chestionar email Formbricks",
"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_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_text": "Parola dumneavoastră a fost schimbată cu succes.",
"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",
"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 ✅",
@@ -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_verify_email": "Verifică emailul",
"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."
},
"environments": {
@@ -500,7 +505,7 @@
"action_copy_failed": "Copierea acțiunii a eșuat",
"action_created_successfully": "Acțiune creată 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_with_key_already_exists": "Acțiunea cu cheia {key} există deja",
"action_with_name_already_exists": "Acțiunea cu numele {name} există deja",
@@ -563,7 +568,7 @@
"congrats": "Felicitări!",
"connection_successful_message": "Bravo! Suntem conectați.",
"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.",
"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.:",
@@ -576,12 +581,10 @@
"contacts_table_refresh": "Reîmprospătare contacte",
"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.",
"first_name": "Prenume",
"last_name": "Nume de familie",
"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",
"select_attribute": "Selectează Atributul",
"select_attribute": "Selectează atributul",
"unlock_contacts_description": "Gestionează contactele și trimite sondaje țintite",
"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.",
@@ -628,7 +631,7 @@
"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!",
"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?",
"google_sheet_integration_description": "Completați instantaneu foile de calcul cu datele chestionarului",
"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. ⏲️",
"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_metadata": "Includere Metadata (Browser, Țară, etc.)",
"include_variables": "Include Variabile",
@@ -767,7 +770,7 @@
"check_out_the_docs": "Consultați documentația.",
"dive_into_the_docs": "Accesați documentația.",
"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_with_environment_id": "Folosit pentru a identifica mediul corect: {environmentId} este al tău.",
"formbricks_sdk": "SDK Formbricks",
@@ -971,52 +974,51 @@
"all_integrations": "Toate integrațiile",
"annually": "Anual",
"api_webhooks": "API & Webhook-uri",
"app_surveys": "Sondaje de Aplicație",
"app_surveys": "Sondaje în aplicație",
"attribute_based_targeting": "Targetare bazată pe atribute",
"current": "Curent",
"current_plan": "Plan curent",
"current_tier_limit": "Limită curentă a nivelului",
"custom": "Personalizat & Scalare",
"custom_contacts_limit": "Limit Personalizat Contacte",
"custom_contacts_limit": "Limită personalizată contacte",
"custom_project_limit": "Limit Personalizat Proiect",
"custom_response_limit": "Limit Personalizat Răspunsuri",
"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.",
"everybody_has_the_free_plan_by_default": "Toată lumea are planul gratuit implicit!",
"everything_in_free": "Totul în Gratuit",
"everything_in_startup": "Totul în Startup",
"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_in_touch": "Contactați-ne",
"hosted_in_frankfurt": "Găzduit în Frankfurt",
"ios_android_sdks": "SDK iOS & Android pentru sondaje mobile",
"link_surveys": "Sondaje Link (Distribuibil)",
"logic_jumps_hidden_fields_recurring_surveys": "Salturi Logice, Câmpuri Ascunse, Sondaje Recurente, etc.",
"manage_card_details": "Gestionați Detaliile Cardului",
"manage_subscription": "Gestionați Abonamentul",
"manage_card_details": "Gestionați detaliile cardului",
"manage_subscription": "Gestionați abonamentul",
"monthly": "Lunar",
"monthly_identified_users": "Utilizatori Identificați Lunar",
"monthly_identified_users": "Utilizatori identificați lunar",
"per_month": "pe lună",
"per_year": "pe an",
"plan_upgraded_successfully": "Planul a fost upgradat cu succes",
"premium_support_with_slas": "Suport premium cu SLA-uri",
"remove_branding": "Eliminare Branding",
"remove_branding": "Eliminare branding",
"startup": "Pornire",
"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}.",
"team_access_roles": "Roluri Acces Echipă",
"team_access_roles": "Roluri acces echipă",
"unable_to_upgrade_plan": "Nu se poate upgrada planul",
"unlimited_miu": "MIU Nelimitat",
"unlimited_projects": "Proiecte Nelimitate",
"unlimited_projects": "Proiecte nelimitate",
"unlimited_responses": "Răspunsuri nelimitate",
"unlimited_surveys": "Sondaje Nelimitate",
"unlimited_team_members": "Membri Nelimitați În Echipă",
"unlimited_surveys": "Sondaje nelimitate",
"unlimited_team_members": "Membri nelimitați în echipă",
"upgrade": "Actualizare",
"uptime_sla_99": "Disponibilitate SLA (99%)",
"website_surveys": "Sondaje ale Site-ului"
"website_surveys": "Sondaje ale site-ului"
},
"enterprise": {
"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.",
"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_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_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_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:",
"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}",
@@ -1074,7 +1076,7 @@
"manage_members_description": "Adăugați sau eliminați membri din organizația dvs.",
"member_deleted_successfully": "Membru șters 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.",
"organization_created_successfully": "Organizație creată cu succes!",
"organization_deleted_successfully": "Organizație ștearsă cu succes!",
@@ -1090,7 +1092,7 @@
"remove_logo": "Înlătură siglă",
"replace_logo": "Înlocuiește sigla",
"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:",
"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",
@@ -1113,9 +1115,9 @@
"account_deletion_consequences_warning": "Consecințele ștergerii contului",
"backup_code": "Cod de rezervă",
"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.",
"delete_account": "Șterge Cont",
"delete_account": "Șterge cont",
"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.",
"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_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_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",
"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.",
@@ -1188,7 +1190,7 @@
}
},
"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",
"copy_survey": "Copiază sondajul",
"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_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_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",
"centered_modal_overlay_color": "Culoare suprapunere modală centralizată",
"change_anyway": "Schimbă oricum",
@@ -1313,7 +1315,7 @@
"conditional_logic": "Logică condițională",
"confirm_default_language": "Confirmați limba implicită",
"confirm_survey_changes": "Confirmă modificările sondajului",
"contact_fields": "C<EFBFBD>mpuri de contact",
"contact_fields": "Câmpuri de contact",
"contains": "Conține",
"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.",
@@ -1340,7 +1342,7 @@
"does_not_include_all_of": "Nu include toate",
"does_not_include_one_of": "Nu include una dintre",
"does_not_start_with": "Nu începe cu",
"edit_recall": "Editează Amintirea",
"edit_recall": "Editează Referințele",
"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_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ț",
"first_name": "Prenume",
"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_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_heading": "Trimitere automată de urmăriri",
"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_empty_heading": "Trimitere automată de follow-up",
"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_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_issue_detected_tag": "Problemă detectată",
"follow_ups_item_response_tag": "Orice răspuns",
"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_body_label": "Corp",
"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_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_name_label": "Numele urmăririi",
"follow_ups_modal_name_placeholder": "Denumirea urmăririi tale",
"follow_ups_modal_name_label": "Numele ",
"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_trigger_description": "Când ar trebui să fie declanșat acest follow-up?",
"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_warning": "Nu s-au găsit finalizări în sondaj!",
"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",
"form_styling": "Stilizare formular",
"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",
"long_answer": "Răspuns lung",
"lower_label": "Etichetă inferioară",
"manage_languages": "Gestionați Limbile",
"manage_languages": "Gestionați limbile",
"max_file_size": "Dimensiune maximă fișier",
"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",
"next_button_label": "Etichetă buton \"Următorul\"",
"next_question": "Întrebarea următoare",
@@ -1512,15 +1514,15 @@
"release_survey_on_date": "Eliberați sondajul la dată",
"remove_description": "Eliminați descrierea",
"remove_translations": "Eliminați traducerile",
"require_answer": "Cere Răspuns",
"require_answer": "Cere răspuns",
"required": "Obligatoriu",
"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.",
"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_limits_redirections_and_more": "Limite de răspunsuri, redirecționări și altele.",
"response_options": "Opțiuni Răspuns",
"roundness": "Rotunjirea",
"response_options": "Opțiuni răspuns",
"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.",
"rows": "Rânduri",
"save_and_close": "Salvează & Închide",
@@ -1580,7 +1582,7 @@
"three_points": "3 puncte",
"times": "ori",
"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”...",
"type_field_id": "ID câmp tip",
"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",
"partial_responses": "Răspunsuri parțiale"
},
"new_survey": "Chestionar Nou",
"new_survey": "Chestionar nou",
"no_surveys_created_yet": "Nu au fost create încă chestionare",
"open_options": "Opțiuni deschise",
"preview_survey_in_a_new_tab": "Previzualizare chestionar în alt tab",
@@ -1732,7 +1734,7 @@
"send_email": {
"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.",
"email_preview_tab": "Previzualizare Email",
"email_preview_tab": "Previzualizare email",
"email_sent": "Email trimis!",
"email_subject_label": "Subiect",
"email_to_label": "Către",
@@ -1776,7 +1778,7 @@
"filter_updated_successfully": "Filtru actualizat cu succes",
"filtered_responses_csv": "Răspunsuri filtrate (CSV)",
"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_tooltip": "Număr de ori când sondajul a fost vizualizat.",
"in_app": {
@@ -1943,7 +1945,7 @@
"intro": {
"get_started": "Începeți",
"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_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!"
@@ -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_lower_label": "Dezacord puternic",
"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_3": "Relații Publice",
"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_placeholder": "Tastează răspunsul aici...",
"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_4": "Podcast",
"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_placeholder": "Tastează răspunsul aici...",
"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_lower_label": "Nesatisfăcut",
"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_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_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_lower_label": "Echilibru foarte slab",
"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_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_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_lower_label": "Neimportant",
"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_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_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_dismiss_button_label": "Nu, mulţumesc",
"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_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_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_2": "Dificil de configurat sau utilizat",
"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_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_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_placeholder": "Tastează răspunsul aici...",
"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_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_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",
@@ -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_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_name": "Întrebare Interviu",
"interview_prompt_name": "Întrebare interviu",
"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_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_upper_label": "Foarte mulțumit",
"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_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_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_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_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_2": "Un fel de...",
"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_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_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_4": "Căutare Google",
"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_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",
"preview_survey_ending_card_description": "Vă rugăm să continuați onboarding-ul.",
"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_lower_label": "Nu este bine",
"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_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_name": "Prioritizați Caracteristicile",
"prioritize_features_name": "Prioritizați caracteristicile",
"prioritize_features_question_1_choice_1": "Caracteristica 1",
"prioritize_features_question_1_choice_2": "Caracteristica 2",
"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_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_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_lower_label": "Nicio oportunitate 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_logo": "新增標誌",
"add_member": "新增成員",
"add_new_project": "新增 新專案",
"add_project": "新增專案",
"add_to_team": "新增至團隊",
"all": "全部",
@@ -149,6 +150,9 @@
"cancel": "取消",
"centered_modal": "置中彈窗",
"choices": "選項",
"choose_environment": "選擇環境",
"choose_organization": "選擇 組織",
"choose_project": "選擇 專案",
"clear_all": "全部清除",
"clear_filters": "清除篩選器",
"clear_selection": "清除選取",
@@ -177,7 +181,6 @@
"created_at": "建立時間",
"created_by": "建立者",
"customer_success": "客戶成功",
"danger_zone": "危險區域",
"dark_overlay": "深色覆蓋",
"date": "日期",
"default": "預設",
@@ -201,6 +204,10 @@
"environment_not_found": "找不到環境",
"environment_notice": "您目前在 '{'environment'}' 環境中。",
"error": "錯誤",
"error_component_description": "此資源不存在或您沒有存取權限。",
"error_component_title": "載入資源錯誤",
"error_rate_limit_description": "已達 到最大 請求 次數。請 稍後 再試。",
"error_rate_limit_title": "限流超過",
"expand_rows": "展開列",
"finish": "完成",
"follow_these": "按照這些步驟",
@@ -232,11 +239,9 @@
"label": "標籤",
"language": "語言",
"learn_more": "瞭解更多",
"license": "授權",
"light_overlay": "淺色覆蓋",
"limits_reached": "已達上限",
"link": "連結",
"link_and_email": "連結與電子郵件",
"link_survey": "連結問卷",
"link_surveys": "連結問卷",
"load_more": "載入更多",
@@ -283,6 +288,7 @@
"organization": "組織",
"organization_id": "組織 ID",
"organization_not_found": "找不到組織",
"organization_settings": "組織設定",
"organization_teams_not_found": "找不到組織團隊",
"other": "其他",
"others": "其他",
@@ -378,7 +384,6 @@
"survey_scheduled": "問卷已排程。",
"survey_type": "問卷類型",
"surveys": "問卷",
"switch_organization": "切換組織",
"switch_to": "切換至 '{'environment'}'",
"table_items_deleted_successfully": "'{'type'}' 已成功刪除",
"table_settings": "表格設定",
@@ -576,8 +581,6 @@
"contacts_table_refresh": "重新整理聯絡人",
"contacts_table_refresh_success": "聯絡人已成功重新整理",
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
"first_name": "名字",
"last_name": "姓氏",
"no_responses_found": "找不到回應",
"not_provided": "未提供",
"search_contact": "搜尋聯絡人",
@@ -989,7 +992,6 @@
"free": "免費",
"free_description": "無限問卷、團隊成員等。",
"get_2_months_free": "免費獲得 2 個月",
"get_in_touch": "取得聯繫",
"hosted_in_frankfurt": "託管在 Frankfurt",
"ios_android_sdks": "iOS 和 Android SDK 用於行動問卷",
"link_surveys": "連結問卷(可分享)",

View File

@@ -1,5 +1,5 @@
import { environmentId, fileUploadQuestion, openTextQuestion, responseData } from "./__mocks__/utils.mock";
import { deleteFile } from "@/lib/storage/service";
import { deleteFile } from "@/modules/storage/service";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { okVoid } from "@formbricks/types/error-handlers";
@@ -11,7 +11,7 @@ vi.mock("@formbricks/logger", () => ({
},
}));
vi.mock("@/lib/storage/service", () => ({
vi.mock("@/modules/storage/service", () => ({
deleteFile: vi.fn(),
}));
@@ -21,7 +21,7 @@ describe("findAndDeleteUploadedFilesInResponse", () => {
});
test("delete files for file upload questions and return okVoid", async () => {
vi.mocked(deleteFile).mockResolvedValue({ success: true, message: "File deleted successfully" });
vi.mocked(deleteFile).mockResolvedValue({ ok: true, data: undefined });
const result = await findAndDeleteUploadedFilesInResponse(responseData, [fileUploadQuestion]);
@@ -56,7 +56,7 @@ describe("findAndDeleteUploadedFilesInResponse", () => {
});
test("process multiple file URLs", async () => {
vi.mocked(deleteFile).mockResolvedValue({ success: true, message: "File deleted successfully" });
vi.mocked(deleteFile).mockResolvedValue({ ok: true, data: undefined });
const result = await findAndDeleteUploadedFilesInResponse(responseData, [fileUploadQuestion]);

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
import { validateFileUploads } from "@/lib/fileValidation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { responses } from "@/modules/api/v2/lib/response";
@@ -8,6 +7,7 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { Response } from "@prisma/client";
import { NextRequest } from "next/server";
import { 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 w-full max-w-sm rounded-xl bg-white p-8 shadow-xl lg:w-96">
<div className="mb-8 text-center">
<Link
target="_blank"
href="https://formbricks.com?utm_source=ce"
rel="noopener noreferrer"
aria-label="Formbricks website">
<Logo className="mx-auto w-3/4" variant="wordmark" aria-hidden="true" />
<Link target="_blank" href="https://formbricks.com?utm_source=ce" rel="noopener noreferrer">
<Logo className="mx-auto w-3/4" />
</Link>
</div>
{children}

View File

@@ -132,6 +132,8 @@ describe("rateLimitConfigs", () => {
{ config: rateLimitConfigs.api.client, identifier: "client-api-key" },
{ config: rateLimitConfigs.api.syncUserIdentification, identifier: "sync-user-id" },
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
{ config: rateLimitConfigs.storage.upload, identifier: "storage-upload" },
{ config: rateLimitConfigs.storage.delete, identifier: "storage-delete" },
];
for (const { config, identifier } of testCases) {
@@ -175,5 +177,23 @@ describe("rateLimitConfigs", () => {
expect(exceededResult.data.allowed).toBe(false);
}
});
test("should properly configure storage upload rate limit", async () => {
const config = rateLimitConfigs.storage.upload;
// Verify configuration values
expect(config.interval).toBe(60); // 1 minute
expect(config.allowedPerInterval).toBe(5); // 5 requests per minute
expect(config.namespace).toBe("storage:upload");
});
test("should properly configure storage delete rate limit", async () => {
const config = rateLimitConfigs.storage.delete;
// Verify configuration values
expect(config.interval).toBe(60); // 1 minute
expect(config.allowedPerInterval).toBe(5); // 5 requests per minute
expect(config.namespace).toBe("storage:delete");
});
});
});

View File

@@ -29,4 +29,9 @@ export const rateLimitConfigs = {
namespace: "action:send-link-survey-email",
}, // 10 per hour
},
storage: {
upload: { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" }, // 5 per minute
delete: { interval: 60, allowedPerInterval: 5, namespace: "storage:delete" }, // 5 per minute
},
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { getOriginalFileNameFromUrl } from "@/lib/storage/utils";
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
import { Column, Container, Img, Link, Row, Text } from "@react-email/components";
import { TFnType } from "@tolgee/react";
import { FileIcon } from "lucide-react";

View File

@@ -149,10 +149,10 @@ describe("AddApiKeyModal", () => {
test("handles label input", async () => {
render(<AddApiKeyModal {...defaultProps} />);
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack");
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack") as HTMLInputElement;
await userEvent.type(labelInput, "Test API Key");
expect((labelInput as HTMLInputElement).value).toBe("Test API Key");
expect(labelInput.value).toBe("Test API Key");
});
test("handles permission changes", async () => {
@@ -184,120 +184,21 @@ describe("AddApiKeyModal", () => {
await userEvent.click(addButton);
// Verify new permission row is added
const deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
const deleteButtons = screen.getAllByRole("button", { name: "" }); // Trash icons
expect(deleteButtons).toHaveLength(2);
// Remove the new permission
await userEvent.click(deleteButtons[1]);
// Check that only the original permission row remains
const remainingDeleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(remainingDeleteButtons).toHaveLength(1);
});
test("removes permissions from middle of list without breaking indices", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Add first permission
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Add second permission
await userEvent.click(addButton);
// Add third permission
await userEvent.click(addButton);
// Verify we have 3 permission rows
let deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(deleteButtons).toHaveLength(3);
// Remove the middle permission (index 1)
await userEvent.click(deleteButtons[1]);
// Verify we now have 2 permission rows
deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(deleteButtons).toHaveLength(2);
// Try to remove the second remaining permission (this was previously index 2, now index 1)
await userEvent.click(deleteButtons[1]);
// Verify we now have 1 permission row
deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(deleteButtons).toHaveLength(1);
// Remove the last remaining permission
await userEvent.click(deleteButtons[0]);
// Verify no permission rows remain
expect(
screen.queryAllByRole("button", { name: "environments.project.api_keys.delete_permission" })
).toHaveLength(0);
});
test("can modify permissions after deleting items from list", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Add multiple permissions
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton); // First permission
await userEvent.click(addButton); // Second permission
await userEvent.click(addButton); // Third permission
// Verify we have 3 permission rows
let deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(deleteButtons).toHaveLength(3);
// Remove the first permission (index 0)
await userEvent.click(deleteButtons[0]);
// Verify we now have 2 permission rows
deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(deleteButtons).toHaveLength(2);
// Try to modify the first remaining permission (which was originally index 1, now index 0)
const projectDropdowns = screen.getAllByRole("button", { name: /Project 1/i });
expect(projectDropdowns.length).toBeGreaterThan(0);
await userEvent.click(projectDropdowns[0]);
// Wait for dropdown content and select 'Project 2'
const project2Option = await screen.findByRole("menuitem", { name: "Project 2" });
await userEvent.click(project2Option);
// Verify project selection by checking the updated button text
const updatedButton = await screen.findByRole("button", { name: "Project 2" });
expect(updatedButton).toBeInTheDocument();
// Add another permission to verify the list is still functional
await userEvent.click(addButton);
// Verify we now have 3 permission rows again
deleteButtons = await screen.findAllByRole("button", {
name: "environments.project.api_keys.delete_permission",
});
expect(deleteButtons).toHaveLength(3);
expect(screen.getAllByRole("button", { name: "" })).toHaveLength(1);
});
test("submits form with correct data", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Fill in label
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack");
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack") as HTMLInputElement;
await userEvent.type(labelInput, "Test API Key");
const addButton = screen.getByRole("button", { name: /add_permission/i });
@@ -377,7 +278,7 @@ describe("AddApiKeyModal", () => {
render(<AddApiKeyModal {...defaultProps} />);
// Type something into the label
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack");
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack") as HTMLInputElement;
await userEvent.type(labelInput, "Test API Key");
// Click the cancel button
@@ -386,219 +287,6 @@ describe("AddApiKeyModal", () => {
// Verify modal is closed and form is reset
expect(mockSetOpen).toHaveBeenCalledWith(false);
expect((labelInput as HTMLInputElement).value).toBe("");
});
test("updates permission field (non-environmentId)", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Add a permission first
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Click on permission level dropdown (third dropdown in the row)
const permissionDropdowns = screen.getAllByRole("button", { name: /read/i });
await userEvent.click(permissionDropdowns[0]);
// Select 'write' permission
const writeOption = await screen.findByRole("menuitem", { name: "write" });
await userEvent.click(writeOption);
// Verify permission selection by checking the updated button text
const updatedButton = await screen.findByRole("button", { name: "write" });
expect(updatedButton).toBeInTheDocument();
});
test("updates environmentId with valid environment", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Add a permission first
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Click on environment dropdown (second dropdown in the row)
const environmentDropdowns = screen.getAllByRole("button", { name: /production/i });
await userEvent.click(environmentDropdowns[0]);
// Select 'development' environment
const developmentOption = await screen.findByRole("menuitem", { name: "development" });
await userEvent.click(developmentOption);
// Verify environment selection by checking the updated button text
const updatedButton = await screen.findByRole("button", { name: "development" });
expect(updatedButton).toBeInTheDocument();
});
test("updates project and automatically selects first environment", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Add a permission first
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Initially should show Project 1 and production environment
expect(screen.getByRole("button", { name: "Project 1" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /production/i })).toBeInTheDocument();
// Click on project dropdown (first dropdown in the row)
const projectDropdowns = screen.getAllByRole("button", { name: /Project 1/i });
await userEvent.click(projectDropdowns[0]);
// Select 'Project 2'
const project2Option = await screen.findByRole("menuitem", { name: "Project 2" });
await userEvent.click(project2Option);
// Verify project selection and that environment was auto-updated
const updatedProjectButton = await screen.findByRole("button", { name: "Project 2" });
expect(updatedProjectButton).toBeInTheDocument();
// Environment should still be production (first environment of Project 2)
expect(screen.getByRole("button", { name: /production/i })).toBeInTheDocument();
});
test("handles edge case when project is not found", async () => {
// Create a modified mock with corrupted project reference
const corruptedProjects = [
{
...mockProjects[0],
id: "different-id", // This will cause project lookup to fail
},
];
render(<AddApiKeyModal {...defaultProps} projects={corruptedProjects} />);
// Add a permission first
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// The component should still render without crashing
expect(screen.getByRole("button", { name: /add_permission/i })).toBeInTheDocument();
// Try to interact with environment dropdown - should not crash
const environmentDropdowns = screen.getAllByRole("button", { name: /production/i });
await userEvent.click(environmentDropdowns[0]);
// Should be able to find and click on development option
const developmentOption = await screen.findByRole("menuitem", { name: "development" });
await userEvent.click(developmentOption);
// Verify environment selection works even when project lookup fails
const updatedButton = await screen.findByRole("button", { name: "development" });
expect(updatedButton).toBeInTheDocument();
});
test("handles edge case when environment is not found", async () => {
// Create a project with no environments
const projectWithNoEnvs = [
{
...mockProjects[0],
environments: [], // No environments available
},
];
render(<AddApiKeyModal {...defaultProps} projects={projectWithNoEnvs} />);
// Try to add a permission - this should handle the case gracefully
const addButton = screen.getByRole("button", { name: /add_permission/i });
// This might not add a permission if no environments exist, which is expected behavior
await userEvent.click(addButton);
// Component should still be functional
expect(screen.getByRole("button", { name: /add_permission/i })).toBeInTheDocument();
});
test("validates duplicate permissions detection", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Fill in a label
const labelInput = screen.getByPlaceholderText("e.g. GitHub, PostHog, Slack");
await userEvent.type(labelInput, "Test API Key");
// Add first permission
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Add second permission with same project/environment
await userEvent.click(addButton);
// Both permissions should now have the same project and environment (Project 1, production)
// Try to submit the form - it should show duplicate error
const submitButton = screen.getByRole("button", {
name: "environments.project.api_keys.add_api_key",
});
await userEvent.click(submitButton);
// The submit should not have been called due to duplicate detection
expect(mockOnSubmit).not.toHaveBeenCalled();
});
test("handles updatePermission with environmentId but environment not found", async () => {
// Create a project with limited environments to test the edge case
const limitedProjects = [
{
...mockProjects[0],
environments: [
{
id: "env1",
type: "production" as const,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "project1",
appSetupCompleted: true,
},
// Only one environment, so we can test when trying to update to non-existent env
],
},
];
render(<AddApiKeyModal {...defaultProps} projects={limitedProjects} />);
// Add a permission first
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Verify permission was added with production environment
expect(screen.getByRole("button", { name: /production/i })).toBeInTheDocument();
// Now test the edge case by manually calling the component's internal logic
// Since we can't directly access the updatePermission function in tests,
// we test through the UI interactions and verify the component doesn't crash
// The component should handle gracefully when environment lookup fails
// This tests the branch: field === "environmentId" && !environment
expect(screen.getByRole("button", { name: /production/i })).toBeInTheDocument();
});
test("covers all branches of updatePermission function", async () => {
render(<AddApiKeyModal {...defaultProps} />);
// Add a permission to have something to update
const addButton = screen.getByRole("button", { name: /add_permission/i });
await userEvent.click(addButton);
// Test Branch 1: Update non-environmentId field (permission level)
const permissionDropdowns = screen.getAllByRole("button", { name: /read/i });
await userEvent.click(permissionDropdowns[0]);
const manageOption = await screen.findByRole("menuitem", { name: "manage" });
await userEvent.click(manageOption);
expect(await screen.findByRole("button", { name: "manage" })).toBeInTheDocument();
// Test Branch 2: Update environmentId with valid environment
const environmentDropdowns = screen.getAllByRole("button", { name: /production/i });
await userEvent.click(environmentDropdowns[0]);
const developmentOption = await screen.findByRole("menuitem", { name: "development" });
await userEvent.click(developmentOption);
expect(await screen.findByRole("button", { name: "development" })).toBeInTheDocument();
// Test Branch 3: Update project (which calls updateProjectAndEnvironment)
const projectDropdowns = screen.getAllByRole("button", { name: /Project 1/i });
await userEvent.click(projectDropdowns[0]);
const project2Option = await screen.findByRole("menuitem", { name: "Project 2" });
await userEvent.click(project2Option);
expect(await screen.findByRole("button", { name: "Project 2" })).toBeInTheDocument();
// Verify all updates worked correctly and component is still functional
expect(screen.getByRole("button", { name: /add_permission/i })).toBeInTheDocument();
expect(labelInput.value).toBe("");
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { Logo } from "@/modules/ui/components/logo";
import { FormbricksLogo } from "@/modules/ui/components/formbricks-logo";
import { Toaster } from "react-hot-toast";
export const SetupLayout = ({ children }: { children: React.ReactNode }) => {
@@ -10,7 +10,7 @@ export const SetupLayout = ({ children }: { children: React.ReactNode }) => {
style={{ scrollbarGutter: "stable both-edges" }}
className="flex max-h-[90vh] w-[40rem] flex-col items-center space-y-4 overflow-auto rounded-lg border bg-white p-12 text-center shadow-md">
<div className="h-20 w-20 rounded-lg bg-slate-900 p-2">
<Logo className="h-full w-full" variant="image" />
<FormbricksLogo className="h-full w-full" />
</div>
{children}
</div>

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as fileUploadModule from "./fileUpload";
import * as fileUploadModule from "./file-upload";
// Mock global fetch
const mockFetch = vi.fn();
@@ -44,13 +44,6 @@ describe("fileUpload", () => {
expect(result.url).toBe("");
});
test("should return error when file is not an image", async () => {
const file = createMockFile("test.pdf", "application/pdf", 1000);
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBe("Please upload an image file.");
expect(result.url).toBe("");
});
test("should return FILE_SIZE_EXCEEDED if arrayBuffer is > 10MB even if file.size is OK", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000); // file.size = 1KB
@@ -108,40 +101,6 @@ describe("fileUpload", () => {
expect(result.url).toBe("https://s3.example.com/file.jpg");
});
test("should handle successful file upload without presigned fields", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Mock successful API response
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
signedUrl: "https://s3.example.com/upload",
fileUrl: "https://s3.example.com/file.jpg",
signingData: {
signature: "test-signature",
timestamp: 1234567890,
uuid: "test-uuid",
},
},
}),
});
// Mock successful upload response
mockFetch.mockResolvedValueOnce({
ok: true,
});
// Simulate FileReader onload
setTimeout(() => {
mockFileReader.onload();
}, 0);
const result = await fileUploadModule.handleFileUpload(file, "test-env");
expect(result.error).toBeUndefined();
expect(result.url).toBe("https://s3.example.com/file.jpg");
});
test("should handle upload error with presigned fields", async () => {
const file = createMockFile("test.jpg", "image/jpeg", 1000);
// Mock successful API response

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 {
isAllowedFileExtension,
isValidFileTypeForExtension,
isValidImageFile,
validateFile,
sanitizeFileName,
validateFileUploads,
validateSingleFile,
} from "./fileValidation";
} from "@/modules/storage/utils";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { StorageErrorCode } from "@formbricks/storage";
import { TResponseData } from "@formbricks/types/responses";
import { ZAllowedFileExtension } from "@formbricks/types/storage";
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
// Mock getOriginalFileNameFromUrl function
vi.mock("@/lib/storage/utils", () => ({
getOriginalFileNameFromUrl: vi.fn((url) => {
// Extract filename from the URL for testing purposes
const parts = url.split("/");
return parts[parts.length - 1];
}),
}));
// Mock the getOriginalFileNameFromUrl function
const mockGetOriginalFileNameFromUrl = vi.hoisted(() => vi.fn());
vi.mock("@/modules/storage/utils", async () => {
const actual = await vi.importActual("@/modules/storage/utils");
return {
...actual,
getOriginalFileNameFromUrl: mockGetOriginalFileNameFromUrl,
};
});
describe("storage utils", () => {
beforeEach(() => {
vi.resetAllMocks();
// Default: derive filename from URL path for positive-path tests
mockGetOriginalFileNameFromUrl.mockImplementation((url: string) => {
try {
return new URL(url).pathname.split("/").filter(Boolean).pop();
} catch {
return undefined;
}
});
});
describe("sanitizeFileName", () => {
test("returns empty string for empty input", () => {
expect(sanitizeFileName("")).toBe("");
});
test("keeps a normal filename unchanged", () => {
expect(sanitizeFileName("photo.jpg")).toBe("photo.jpg");
});
test("replaces slashes and backslashes with dashes", () => {
expect(sanitizeFileName("a/b\\c.txt")).toBe("a-b-c.txt");
});
test("removes reserved and control characters including #", () => {
expect(sanitizeFileName("we<>:\"|?*`'#ird.pdf")).toBe("weird.pdf");
expect(sanitizeFileName("test#file.png")).toBe("testfile.png");
});
test("collapses whitespace and trims", () => {
expect(sanitizeFileName(" my file name .jpg ")).toBe("my file name.jpg");
});
test("keeps only last extension when multiple dots present", () => {
expect(sanitizeFileName("my.backup.file.pdf")).toBe("my.backup.file.pdf");
});
test("returns empty string for base of only hyphens or dots", () => {
expect(sanitizeFileName("----.png")).toBe("");
expect(sanitizeFileName("....png")).toBe("");
});
test("sanitizes extension to alphanumeric only", () => {
expect(sanitizeFileName("file.pn#g")).toBe("file.png");
});
test("truncates overly long base name", () => {
const longBase = "a".repeat(300);
const result = sanitizeFileName(`${longBase}.txt`);
// base should be cut to 200 chars
expect(result).toBe(`${"a".repeat(200)}.txt`);
});
});
describe("getErrorResponseFromStorageError", () => {
test("returns appropriate responses for each storage error code", async () => {
// Spy on real module; keep behavior isolated to this test
const responseMod = await import("@/app/lib/api/response");
const spyNotFound = vi
.spyOn(responseMod.responses, "notFoundResponse")
.mockImplementation(
(_entity: string, _id?: string | null, _public?: boolean) => new Response(null, { status: 404 })
);
const spyBadReq = vi
.spyOn(responseMod.responses, "badRequestResponse")
.mockImplementation(
(_msg: string, _details?: unknown, _public?: boolean) => new Response(null, { status: 400 })
);
const spyISE = vi
.spyOn(responseMod.responses, "internalServerErrorResponse")
.mockImplementation((_msg: string, _public?: boolean) => new Response(null, { status: 500 }));
const { getErrorResponseFromStorageError } = await import("@/modules/storage/utils");
// FileNotFoundError uses notFoundResponse with details.fileName or null
const r404 = getErrorResponseFromStorageError(
{ code: StorageErrorCode.FileNotFoundError },
{
fileName: "file.png",
}
);
expect(r404.status).toBe(404);
// InvalidInput -> 400
const r400 = getErrorResponseFromStorageError({ code: StorageErrorCode.InvalidInput }, {
reason: "bad",
} as any);
expect(r400.status).toBe(400);
// S3 related and Unknown -> 500
const r500a = getErrorResponseFromStorageError({ code: StorageErrorCode.S3ClientError });
expect(r500a.status).toBe(500);
const r500b = getErrorResponseFromStorageError({ code: StorageErrorCode.S3CredentialsError });
expect(r500b.status).toBe(500);
const r500c = getErrorResponseFromStorageError({ code: StorageErrorCode.Unknown });
expect(r500c.status).toBe(500);
// Default branch (unknown string) -> 500
const r500d = getErrorResponseFromStorageError({ code: "something_else" as any });
expect(r500d.status).toBe(500);
spyNotFound.mockRestore();
spyBadReq.mockRestore();
spyISE.mockRestore();
});
});
describe("getOriginalFileNameFromUrl (actual)", () => {
test("extracts original name from full URL with fid and extension", async () => {
const { getOriginalFileNameFromUrl } =
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
const url = "https://cdn.example.com/storage/env/public/photo--fid--12345.png?x=1#hash";
expect(getOriginalFileNameFromUrl(url)).toBe("photo.png");
});
test("handles /storage/ relative path and missing fid", async () => {
const { getOriginalFileNameFromUrl } =
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
const path = "/storage/env/public/Document%20Name.pdf";
expect(getOriginalFileNameFromUrl(path)).toBe("/storage/env/public/Document Name.pdf");
});
test("returns empty string on invalid URL input", async () => {
const { getOriginalFileNameFromUrl } =
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
expect(getOriginalFileNameFromUrl("ht!tp://%$^&")).toBe("");
});
});
describe("fileValidation", () => {
describe("isAllowedFileExtension", () => {
test("should return false for a file with no extension", () => {
expect(isAllowedFileExtension("filename")).toBe(false);
@@ -74,70 +207,24 @@ describe("fileValidation", () => {
});
});
describe("validateFile", () => {
test("should return valid: false when file extension is not allowed", () => {
const result = validateFile("script.php", "application/php");
expect(result.valid).toBe(false);
expect(result.error).toContain("File type not allowed");
});
test("should return valid: false when file type does not match extension", () => {
const result = validateFile("image.png", "application/pdf");
expect(result.valid).toBe(false);
expect(result.error).toContain("File type doesn't match");
});
test("should return valid: true when file is allowed and type matches extension", () => {
const result = validateFile("image.jpg", "image/jpeg");
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
test("should return valid: true for allowed file types", () => {
Object.values(ZAllowedFileExtension.enum).forEach((ext) => {
// Skip testing extensions that don't have defined MIME types in the test
if (["jpg", "png", "pdf"].includes(ext)) {
const mimeType = ext === "jpg" ? "image/jpeg" : ext === "png" ? "image/png" : "application/pdf";
const result = validateFile(`file.${ext}`, mimeType);
expect(result.valid).toBe(true);
}
});
});
test("should return valid: false for files with no extension", () => {
const result = validateFile("noextension", "application/octet-stream");
expect(result.valid).toBe(false);
});
test("should handle attempts to bypass with double extension", () => {
const result = validateFile("malicious.jpg.php", "image/jpeg");
expect(result.valid).toBe(false);
});
});
describe("validateSingleFile", () => {
test("should return true for allowed file extension", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("image.jpg");
mockGetOriginalFileNameFromUrl.mockReturnValueOnce("image.jpg");
expect(validateSingleFile("https://example.com/image.jpg", ["jpg", "png"])).toBe(true);
});
test("should return false for disallowed file extension", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("malicious.exe");
mockGetOriginalFileNameFromUrl.mockReturnValueOnce("malicious.exe");
expect(validateSingleFile("https://example.com/malicious.exe", ["jpg", "png"])).toBe(false);
});
test("should return true when no allowed extensions are specified", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("image.jpg");
mockGetOriginalFileNameFromUrl.mockReturnValueOnce("image.jpg");
expect(validateSingleFile("https://example.com/image.jpg")).toBe(true);
});
test("should return false when file name cannot be extracted", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce(undefined);
expect(validateSingleFile("https://example.com/unknown")).toBe(false);
});
test("should return false when file has no extension", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("filewithoutextension");
mockGetOriginalFileNameFromUrl.mockReturnValueOnce("filewithoutextension");
expect(validateSingleFile("https://example.com/filewithoutextension", ["jpg"])).toBe(false);
});
});
@@ -209,7 +296,7 @@ describe("fileValidation", () => {
test("should return false when file name cannot be extracted", () => {
// Mock implementation to return null for this specific URL
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => undefined);
mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => undefined);
const responseData = {
question1: ["https://example.com/invalid-url"],
@@ -227,9 +314,7 @@ describe("fileValidation", () => {
});
test("should return false when file has no extension", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(
() => "file-without-extension"
);
mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => "file-without-extension");
const responseData = {
question1: ["https://example.com/storage/file-without-extension"],
@@ -292,24 +377,22 @@ describe("fileValidation", () => {
});
test("should return false when file name cannot be extracted", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => undefined);
mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => undefined);
expect(isValidImageFile("https://example.com/invalid-url")).toBe(false);
});
test("should return false when file has no extension", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(
() => "image-without-extension"
);
mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => "image-without-extension");
expect(isValidImageFile("https://example.com/image-without-extension")).toBe(false);
});
test("should return false when file name ends with a dot", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => "image.");
mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => "image.");
expect(isValidImageFile("https://example.com/image.")).toBe(false);
});
test("should handle case insensitivity correctly", () => {
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => "image.JPG");
mockGetOriginalFileNameFromUrl.mockImplementationOnce(() => "image.JPG");
expect(isValidImageFile("https://example.com/image.JPG")).toBe(true);
});
});

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 { updateUser } from "./user";
vi.mock("@/lib/fileValidation", () => ({
vi.mock("@/modules/storage/utils", () => ({
isValidImageFile: vi.fn(),
}));

View File

@@ -13,7 +13,7 @@ import { PlusIcon, XCircleIcon } from "lucide-react";
import Link from "next/link";
import { type JSX, useMemo, useState } from "react";
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 { TUserLocale } from "@formbricks/types/user";

View File

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

View File

@@ -1,7 +1,7 @@
"use client";
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 Image, { StaticImageData } from "next/image";
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 justify-center -space-x-4">
<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 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" />

View File

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

View File

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

View File

@@ -1,11 +1,20 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TAllowedFileExtension } from "@formbricks/types/common";
import { TAllowedFileExtension } from "@formbricks/types/storage";
import { FileInput } from "./index";
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
},
error: vi.fn(),
}));
// 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" }),
}));
@@ -48,6 +57,26 @@ describe("FileInput", () => {
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", () => {
const fileUrl = "https://example.com/test-image.jpg";
render(<FileInput {...defaultProps} fileUrl={fileUrl} />);

View File

@@ -1,7 +1,7 @@
"use client";
import { handleFileUpload } from "@/app/lib/fileUpload";
import { cn } from "@/lib/cn";
import { FileUploadError, handleFileUpload } from "@/modules/storage/file-upload";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
import { useTranslate } from "@tolgee/react";
@@ -9,7 +9,7 @@ import { FileIcon, XIcon } from "lucide-react";
import Image from "next/image";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { TAllowedFileExtension } from "@formbricks/types/common";
import { TAllowedFileExtension } from "@formbricks/types/storage";
import { Uploader } from "./components/uploader";
import { VideoSettings } from "./components/video-settings";
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 === 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"));
} else {
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 === 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"));
} else {
toast.error(t("common.some_files_failed_to_upload"));

View File

@@ -1,6 +1,6 @@
import { toast } from "react-hot-toast";
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 { checkForYoutubePrivacyMode, getAllowedFiles } from "./utils";

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
"use client";
import { getOriginalFileNameFromUrl } from "@/lib/storage/utils";
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
import { useTranslate } from "@tolgee/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();
});
describe("default variant", () => {
test("renders default logo correctly", () => {
const { container } = render(<Logo />);
const svg = container.querySelector("svg");
test("renders correctly", () => {
const { container } = render(<Logo />);
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("renders image logo correctly", () => {
const { container } = render(<Logo variant="image" />);
const svg = container.querySelector("svg");
test("accepts and passes through props", () => {
const testClassName = "test-class";
const { container } = render(<Logo className={testClassName} />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
});
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);
});
expect(svg).toBeInTheDocument();
expect(svg).toHaveAttribute("class", testClassName);
});
describe("wordmark variant", () => {
test("renders wordmark logo correctly", () => {
const { container } = render(<Logo variant="wordmark" />);
const svg = container.querySelector("svg");
test("contains expected svg elements", () => {
const { container } = render(<Logo />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
});
test("renders wordmark logo with className correctly", () => {
const testClassName = "test-class";
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);
});
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> {
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>) => {
export const Logo = (props: any) => {
return (
<svg viewBox="0 0 697 150" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<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: {
background: {
bgType: "upload",
bg: "/uploads/test-image.jpg",
bg: "/files/test-image.jpg",
brightness: 100,
},
} as TProjectStyling,
@@ -106,7 +106,7 @@ describe("MediaBackground", () => {
render(<MediaBackground {...props} />);
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", () => {

View File

@@ -164,7 +164,7 @@ const nextConfig = {
},
{
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",
@@ -452,8 +452,8 @@ const sentryOptions = {
};
const exportConfig =
(process.env.SENTRY_DSN && process.env.NODE_ENV === "production")
? withSentryConfig(nextConfig, sentryOptions) :
nextConfig;
process.env.SENTRY_DSN && process.env.NODE_ENV === "production"
? withSentryConfig(nextConfig, sentryOptions)
: nextConfig;
export default exportConfig;

View File

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

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