diff --git a/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.test.ts b/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.test.ts index fceb2b270595..83d72e4a6aa0 100644 --- a/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.test.ts +++ b/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.test.ts @@ -12,7 +12,7 @@ const mocks = vi.hoisted(() => ({ resolveClientApiIds: vi.fn(), sendToPipeline: vi.fn(), updateResponseWithQuotaEvaluation: vi.fn(), - validateFileUploads: vi.fn(), + validateClientFileUploads: vi.fn(), validateOtherOptionLengthForMultipleChoice: vi.fn(), validateResponseData: vi.fn(), })); @@ -49,7 +49,7 @@ vi.mock("@/modules/api/v2/lib/element", () => ({ })); vi.mock("@/modules/storage/utils", () => ({ - validateFileUploads: mocks.validateFileUploads, + validateClientFileUploads: mocks.validateClientFileUploads, })); vi.mock("./response", () => ({ @@ -130,7 +130,7 @@ describe("putResponseHandler", () => { mocks.getSurvey.mockResolvedValue(getBaseSurvey()); mocks.resolveClientApiIds.mockResolvedValue({ workspaceId }); mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse()); - mocks.validateFileUploads.mockReturnValue(true); + mocks.validateClientFileUploads.mockReturnValue(true); mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null); mocks.validateResponseData.mockReturnValue(null); }); @@ -312,7 +312,7 @@ describe("putResponseHandler", () => { }); test("rejects invalid file upload updates", async () => { - mocks.validateFileUploads.mockReturnValue(false); + mocks.validateClientFileUploads.mockReturnValue(false); const result = await putResponseHandler(createHandlerParams()); diff --git a/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.ts b/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.ts index 4d88fdc83b24..ea1252197544 100644 --- a/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.ts +++ b/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.ts @@ -12,7 +12,7 @@ import { resolveClientApiIds } from "@/lib/utils/resolve-client-id"; import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation"; import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element"; import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers"; -import { validateFileUploads } from "@/modules/storage/utils"; +import { validateClientFileUploads } from "@/modules/storage/utils"; import { updateResponseWithQuotaEvaluation } from "./response"; import { getValidatedResponseUpdateInput } from "./validated-response-update-input"; @@ -127,7 +127,8 @@ const getSurveyForResponse = async ( const validateUpdateRequest = ( existingResponse: TResponse, survey: TSurvey, - responseUpdateInput: TResponseUpdateInput + responseUpdateInput: TResponseUpdateInput, + workspaceId: string ): TRouteResult | undefined => { if (existingResponse.finished) { return { @@ -135,7 +136,15 @@ const validateUpdateRequest = ( }; } - if (!validateFileUploads(responseUpdateInput.data, survey.questions)) { + if ( + !validateClientFileUploads({ + data: responseUpdateInput.data, + workspaceId, + surveyId: survey.id, + blocks: survey.blocks, + questions: survey.questions, + }) + ) { return { response: responses.badRequestResponse("Invalid file upload response", undefined, true), }; @@ -250,7 +259,7 @@ export const putResponseHandler = async ({ }; } - const validationResult = validateUpdateRequest(existingResponse, survey, responseUpdateInput); + const validationResult = validateUpdateRequest(existingResponse, survey, responseUpdateInput, workspaceId); if (validationResult) { return validationResult; } diff --git a/apps/web/app/api/v1/client/[workspaceId]/responses/route.ts b/apps/web/app/api/v1/client/[workspaceId]/responses/route.ts index 30fbe85581bb..ce528d7d0407 100644 --- a/apps/web/app/api/v1/client/[workspaceId]/responses/route.ts +++ b/apps/web/app/api/v1/client/[workspaceId]/responses/route.ts @@ -18,7 +18,7 @@ import { resolveClientApiIds } from "@/lib/utils/resolve-client-id"; import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers"; -import { validateFileUploads } from "@/modules/storage/utils"; +import { validateClientFileUploads } from "@/modules/storage/utils"; import { createResponseWithQuotaEvaluation } from "./lib/response"; export const OPTIONS = async (): Promise => { @@ -154,7 +154,15 @@ export const POST = withV1ApiWrapper({ responseInputData.singleUseId = singleUseValidationResult.singleUseId; } - if (!validateFileUploads(responseInputData.data, survey.questions)) { + if ( + !validateClientFileUploads({ + data: responseInputData.data, + workspaceId, + surveyId: survey.id, + blocks: survey.blocks, + questions: survey.questions, + }) + ) { return { response: responses.badRequestResponse("Invalid file upload response"), }; diff --git a/apps/web/app/api/v1/client/[workspaceId]/storage/route.ts b/apps/web/app/api/v1/client/[workspaceId]/storage/route.ts index 8423dc7aba03..deb32d681cf7 100644 --- a/apps/web/app/api/v1/client/[workspaceId]/storage/route.ts +++ b/apps/web/app/api/v1/client/[workspaceId]/storage/route.ts @@ -27,7 +27,7 @@ export const OPTIONS = async (): Promise => { // 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 file upload question response for example +// use this to let users upload files to a file upload element response for example export const POST = withV1ApiWrapper({ handler: async ({ req, props }: THandlerParams<{ params: Promise<{ workspaceId: string }> }>) => { @@ -66,7 +66,7 @@ export const POST = withV1ApiWrapper({ }; } - const { fileName, fileType, surveyId } = parsedInputResult.data; + const { fileName, fileType, surveyId, elementId } = parsedInputResult.data; const [survey, organizationId] = await Promise.all([ getSurvey(surveyId), @@ -109,18 +109,23 @@ export const POST = withV1ApiWrapper({ const fileUploadPermission = validateSurveyAllowsFileUpload({ fileName, + elementId, blocks: survey.blocks, questions: survey.questions, }); if (!fileUploadPermission.ok) { + let responseString: string = ""; + if (fileUploadPermission.reason === "no_file_upload_element") { + responseString = "Survey does not allow file uploads"; + } else if (fileUploadPermission.reason === "file_upload_element_not_found") { + responseString = "Element does not allow file uploads"; + } else { + responseString = "File extension is not allowed for this element"; + } + return { - response: responses.badRequestResponse( - fileUploadPermission.reason === "no_file_upload_question" - ? "Survey does not allow file uploads" - : "File extension is not allowed for this survey", - undefined - ), + response: responses.badRequestResponse(responseString, undefined), }; } @@ -134,7 +139,8 @@ export const POST = withV1ApiWrapper({ workspaceId, fileType, "private", - maxFileUploadSize + maxFileUploadSize, + ["surveys", surveyId, "elements", elementId] ); if (!signedUrlResponse.ok) { diff --git a/apps/web/app/api/v2/client/[workspaceId]/responses/route.ts b/apps/web/app/api/v2/client/[workspaceId]/responses/route.ts index 5dad57048cf7..dfe74137b075 100644 --- a/apps/web/app/api/v2/client/[workspaceId]/responses/route.ts +++ b/apps/web/app/api/v2/client/[workspaceId]/responses/route.ts @@ -15,6 +15,7 @@ import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/ import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers"; +import { validateClientFileUploads } from "@/modules/storage/utils"; import { createResponseWithQuotaEvaluation } from "./lib/response"; import { TResponseInputV2, ZResponseInputV2 } from "./types/response"; @@ -89,6 +90,18 @@ const validateResponseSubmission = async ( return surveyCheckResult; } + if ( + !validateClientFileUploads({ + data: responseInputData.data, + workspaceId, + surveyId: survey.id, + blocks: survey.blocks, + questions: survey.questions, + }) + ) { + return responses.badRequestResponse("Invalid file upload response", undefined, true); + } + const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({ responseData: responseInputData.data, surveyQuestions: getElementsFromBlocks(survey.blocks), diff --git a/apps/web/app/storage/[workspaceId]/[accessType]/[fileName]/lib/audit-logs.test.ts b/apps/web/app/storage/[workspaceId]/[accessType]/[...filePath]/lib/audit-logs.test.ts similarity index 100% rename from apps/web/app/storage/[workspaceId]/[accessType]/[fileName]/lib/audit-logs.test.ts rename to apps/web/app/storage/[workspaceId]/[accessType]/[...filePath]/lib/audit-logs.test.ts diff --git a/apps/web/app/storage/[workspaceId]/[accessType]/[fileName]/lib/audit-logs.ts b/apps/web/app/storage/[workspaceId]/[accessType]/[...filePath]/lib/audit-logs.ts similarity index 100% rename from apps/web/app/storage/[workspaceId]/[accessType]/[fileName]/lib/audit-logs.ts rename to apps/web/app/storage/[workspaceId]/[accessType]/[...filePath]/lib/audit-logs.ts diff --git a/apps/web/app/storage/[workspaceId]/[accessType]/[fileName]/lib/auth.ts b/apps/web/app/storage/[workspaceId]/[accessType]/[...filePath]/lib/auth.ts similarity index 100% rename from apps/web/app/storage/[workspaceId]/[accessType]/[fileName]/lib/auth.ts rename to apps/web/app/storage/[workspaceId]/[accessType]/[...filePath]/lib/auth.ts diff --git a/apps/web/app/storage/[workspaceId]/[accessType]/[fileName]/route.ts b/apps/web/app/storage/[workspaceId]/[accessType]/[...filePath]/route.ts similarity index 85% rename from apps/web/app/storage/[workspaceId]/[accessType]/[fileName]/route.ts rename to apps/web/app/storage/[workspaceId]/[accessType]/[...filePath]/route.ts index 8644e94abda6..d346fd1f368c 100644 --- a/apps/web/app/storage/[workspaceId]/[accessType]/[fileName]/route.ts +++ b/apps/web/app/storage/[workspaceId]/[accessType]/[...filePath]/route.ts @@ -4,7 +4,7 @@ import { logger } from "@formbricks/logger"; import { ZDeleteFileRequest, ZDownloadFileRequest } from "@formbricks/types/storage"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; -import { authorizePrivateDownload } from "@/app/storage/[workspaceId]/[accessType]/[fileName]/lib/auth"; +import { authorizePrivateDownload } from "@/app/storage/[workspaceId]/[accessType]/[...filePath]/lib/auth"; import { resolveClientApiIds } from "@/lib/utils/resolve-client-id"; import { authOptions } from "@/modules/auth/lib/authOptions"; import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; @@ -15,10 +15,14 @@ import { logFileDeletion } from "./lib/audit-logs"; export const GET = async ( request: NextRequest, - props: { params: Promise<{ workspaceId: string; accessType: string; fileName: string }> } + props: { params: Promise<{ workspaceId: string; accessType: string; filePath: string[] }> } ): Promise => { const params = await props.params; - const paramValidation = ZDownloadFileRequest.safeParse(params); + const fileName = params.filePath.join("/"); + const paramValidation = ZDownloadFileRequest.safeParse({ + accessType: params.accessType, + fileName, + }); if (!paramValidation.success) { return responses.badRequestResponse( @@ -28,7 +32,7 @@ export const GET = async ( ); } - const { accessType, fileName } = paramValidation.data; + const { accessType } = paramValidation.data; const idParam = params.workspaceId; // Resolve: the URL param may be an environmentId (old uploads) or workspaceId (new uploads) @@ -72,10 +76,14 @@ export const GET = async ( export const DELETE = async ( request: NextRequest, - props: { params: Promise<{ workspaceId: string; accessType: string; fileName: string }> } + props: { params: Promise<{ workspaceId: string; accessType: string; filePath: string[] }> } ): Promise => { const params = await props.params; - const paramValidation = ZDeleteFileRequest.safeParse(params); + const fileName = params.filePath.join("/"); + const paramValidation = ZDeleteFileRequest.safeParse({ + accessType: params.accessType, + fileName, + }); if (!paramValidation.success) { const errorDetails = transformErrorToDetails(paramValidation.error); @@ -88,7 +96,7 @@ export const DELETE = async ( return responses.badRequestResponse("Fields are missing or incorrectly formatted", errorDetails, true); } - const { accessType, fileName } = paramValidation.data; + const { accessType } = paramValidation.data; const idParam = params.workspaceId; // Resolve: the URL param may be an environmentId (old uploads) or workspaceId (new uploads) @@ -140,6 +148,20 @@ export const DELETE = async ( ); if (!deleteResult.ok) { + if (!("error" in deleteResult)) { + logger.error({ deleteResult }, "Unknown delete failure result shape"); + + await logFileDeletion({ + failureReason: "unknown_delete_failure", + accessType, + userId: session?.user?.id, + workspaceId: resolved.workspaceId, + apiUrl: request.url, + }); + + return responses.internalServerErrorResponse("Failed to delete file", true); + } + const { error } = deleteResult; logger.error({ error }, "Error deleting file"); diff --git a/apps/web/lib/response/service.ts b/apps/web/lib/response/service.ts index 27e2198cd85f..52604d84107e 100644 --- a/apps/web/lib/response/service.ts +++ b/apps/web/lib/response/service.ts @@ -23,7 +23,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils"; import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils"; import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas"; import { deleteFile } from "@/modules/storage/service"; -import { resolveStorageUrlsInObject } from "@/modules/storage/utils"; +import { parseStorageFileUrl, resolveStorageUrlsInObject } from "@/modules/storage/utils"; import { getOrganizationIdFromWorkspaceId } from "@/modules/survey/lib/organization"; import { getOrganizationBilling } from "@/modules/survey/lib/survey"; import { ITEMS_PER_PAGE } from "../constants"; @@ -597,14 +597,18 @@ const findAndDeleteUploadedFilesInResponse = async (response: TResponse, survey: const deletionPromises = fileUrls.map(async (fileUrl) => { try { - const { pathname } = new URL(fileUrl); - const [, storageId, accessType, fileName] = pathname.split("/").filter(Boolean); + const storageFile = parseStorageFileUrl(fileUrl); - if (!storageId || !accessType || !fileName) { - throw new Error(`Invalid file path: ${pathname}`); + if (!storageFile) { + throw new Error(`Invalid storage file URL: ${fileUrl}`); } - return deleteFile(storageId, accessType as "private" | "public", fileName, survey.workspaceId); + return deleteFile( + storageFile.storageId, + storageFile.accessType, + storageFile.fileName, + survey.workspaceId + ); } catch (error) { logger.error(error, `Failed to delete file ${fileUrl}`); } diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index 18dd7506839a..4a4ab4f4b907 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -77,7 +77,7 @@ "password_validation_uppercase_and_lowercase": "Nagybetűk és kisbetűk vegyesen", "please_verify_captcha": "Ellenőrizze a reCAPTCHA-t", "privacy_policy": "Adatvédelmi irányelvek", - "product_updates_description": "Szeretnék havi termékfrissítési e-maileket kapni a Formbricks-től. Az Adatvédelmi Szabályzat alkalmazandó.", + "product_updates_description": "Szeretnék havi termékfrissítési e-maileket kapni a Formbrickstől. Az adatvédelmi irányelvek alkalmazandók.", "product_updates_title": "Havi termékfrissítési e-mailek", "security_updates_description": "Csak biztonságra vonatkozó információk, adatvédelmi irányelvek alkalmazása.", "security_updates_title": "Biztonsági frissítések", @@ -250,9 +250,9 @@ "failed_to_load_workspaces": "Nem sikerült a munkaterületek betöltése", "failed_to_parse_csv": "A CSV elemzése sikertelen", "field_placeholder": "{field} helykitöltő", - "file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.", - "file_storage_not_set_up": "File storage not set up", - "file_upload_service_unavailable": "File upload service unavailable", + "file_size_must_be_less_than_5_mb": "A fájlméretnek kisebbnek kell lennie mint 5 MB.", + "file_storage_not_set_up": "A fájltároló nincs beállítva", + "file_upload_service_unavailable": "A fájlfeltöltési szolgáltatás nem érhető el", "filter": "Szűrő", "finish": "Befejezés", "first_name": "Keresztnév", @@ -518,7 +518,7 @@ "you_are_downgraded_to_the_community_edition": "Visszaváltott a közösségi kiadásra.", "you_are_not_authorized_to_perform_this_action": "Nincs felhatalmazva ennek a műveletnek a végrehajtásához.", "you_have_reached_your_limit_of_workspace_limit": "Elérte a munkaterületek {workspaceLimit} darabos korlátját.", - "you_have_reached_your_monthly_response_limit_of_count": "Elérte havi válaszlimitjét, amely {count} darab.", + "you_have_reached_your_monthly_response_limit_of_count": "Elérte a havi {count} értékű válaszkorlátját.", "you_will_be_downgraded_to_the_community_edition_on_date": "Vissza lesz állítva a közösségi kiadásra ekkor: {date}.", "your_license_has_expired_please_renew": "A vállalati licence lejárt. Újítsa meg, hogy továbbra is használhassa a vállalati funkciókat.", "your_profile": "Az Ön Profilja" @@ -666,7 +666,7 @@ "add_another_member": "Másik tag hozzáadása", "continue": "Folytatás", "failed_to_invite": "Nem sikerült meghívni", - "invitation_sent_to_email": "Meghívó elküldve a következő címre: {email}", + "invitation_sent_to_email": "A meghívó elküldve ide: {email}!", "invite_your_organization_members": "Szervezeti tagok meghívása", "life_s_no_fun_alone": "Az élet nem szórakoztató egyedül.", "skip": "Kihagyás", @@ -2073,7 +2073,7 @@ "create_survey_warning": "Létre kell hoznia egy kérdőívet, hogy képes legyen beállítani ezt az integrációt", "delete_integration": "Integráció törlése", "delete_integration_confirmation": "Biztosan törölni szeretné ezt az integrációt?", - "follow_these_docs_to_configure_it": "Kövesse ezt a dokumentációt a konfiguráláshoz.", + "follow_these_docs_to_configure_it": "Kövesse ezeket a dokumentációkat a beállításához.", "google_sheet_integration_description": "A táblázatok azonnali feltöltése a kérdőív adataival", "google_sheets": { "connect_with_google_sheets": "Kapcsolódás a Google Táblázatokhoz", @@ -2395,7 +2395,7 @@ "segment_id": "Szakaszazonosító", "segment_saved_successfully": "A szakasz sikeresen elmentve", "segment_updated_successfully": "A szakasz sikeresen frissítve", - "segment_used_in_other_surveys_make_changes_here": "Ez a szegmens más felmérésekben is használatos. Módosításokat itt végezhet.", + "segment_used_in_other_surveys_make_changes_here": "Ez a szakasz más kérdőívekben is használva van. Itt végezze el a változtatásokat.", "segments_help_you_target_users_with_same_characteristics_easily": "A szakaszok segítik a hasonló jellemzőkkel rendelkező felhasználók könnyű megcélzását", "target_audience": "Célközönség", "this_action_resets_all_filters_in_this_survey": "Ez a művelet visszaállítja az összes szűrőt ebben a kérdőívben.", @@ -2403,7 +2403,7 @@ "unknown_filter_type": "Ismeretlen szűrőtípus", "unlock_segments_description": "Partnerek szakaszokba szervezése meghatározott felhasználói csoportok megcélzásához", "unlock_segments_title": "Szakaszok feloldása egy magasabb csomaggal", - "user_targeting_only_available_when_identifying_users": "A felhasználói célzás jelenleg csak akkor érhető el, ha azonosítja a felhasználókat a Formbricks SDK használatával.", + "user_targeting_only_available_when_identifying_users": "A felhasználók megcélzása jelenleg csak akkor érhető el, ha a Formbricks SDK-val azonosítja a felhasználókat.", "value_cannot_be_empty": "Az érték nem lehet üres.", "value_must_be_a_number": "Az értéknek számnak kell lennie.", "value_must_be_positive": "Az értéknek pozitív számnak kell lennie.", @@ -2550,7 +2550,7 @@ "no_credit_card_no_sales_call_just_test_it": "Nem kell hitelkártya. Nincsenek értékesítési hívások. Egyszerűen csak próbálja ki :)", "on_request": "Kérésre", "organization_roles": "Szervezeti szerepek (adminisztrátor, szerkesztő, fejlesztő stb.)", - "questions_please_reach_out_to_email": "Kérdése van? Kérjük, vegye fel a kapcsolatot velünk: hola@formbricks.com", + "questions_please_reach_out_to_email": "Kérdése van? Írjon nekünk a hola@formbricks.com e-mail-címre.", "recheck_license": "Licenc újraellenőrzése", "recheck_license_failed": "A licencellenőrzés nem sikerült. Lehet, hogy a licenckiszolgáló nem érhető el.", "recheck_license_instance_mismatch": "Ez a licenc egy másik Formbricks-példányhoz van kötve. Kérje meg a Formbricks ügyfélszolgálatát, hogy szüntessék meg a korábbi kötést.", @@ -2817,7 +2817,7 @@ "address_line_2": "Cím 2. sora", "adjust_survey_closed_message": "A „Kérdőív lezárva” üzenet módosítása", "adjust_survey_closed_message_description": "Annak az üzenetnek a megváltoztatása, amelyet a látogatók akkor látnak, amikor a kérdőív lezárul.", - "adjust_theme_in_look_and_feel_settings": "A témát a Megjelenés és Élmény beállításokban módosíthatja.", + "adjust_theme_in_look_and_feel_settings": "A téma módosítása a megjelenési beállításokban.", "ai_features_not_enabled": "Az AI funkciók nincsenek engedélyezve ezen szervezet számára.", "ai_instance_not_configured": "Az AI nincs konfigurálva. Kérjük, forduljon a rendszergazdájához.", "ai_smart_tools_disabled": "Az AI intelligens eszközök le vannak tiltva ezen szervezet számára.", @@ -2830,7 +2830,7 @@ "ai_translation_not_available": "A mesterséges intelligencia által támogatott fordítás nem érhető el az Ön jelenlegi csomagjában. Frissítsen ennek a funkciónak a feloldásához.", "ai_translation_not_enabled": "Az AI intelligens eszközök le vannak tiltva ennél a szervezetnél. Engedélyezze őket a szervezeti beállításokban.", "all_are_true": "az összes igaz", - "all_other_answers_will_continue_to_fallback": "Minden egyéb válasz továbbra is fog", + "all_other_answers_will_continue_to_fallback": "Az összes többi válasz továbbra is ", "allow_multi_select": "Több választás engedélyezése", "allow_multiple_files": "Több fájl engedélyezése", "allow_users_to_select_more_than_one_image": "Lehetővé tétel a felhasználóknak, hogy egynél több képet válasszanak ki", @@ -2845,10 +2845,10 @@ "auto_save_disabled": "Az automatikus mentés letiltva", "auto_save_disabled_tooltip": "A kérdőív csak akkor kerül automatikusan mentésre, ha piszkozatban van. Ez biztosítja, hogy a nyilvános kérdőívek ne legyenek véletlenül frissítve.", "auto_save_on": "Automatikus mentés bekapcsolva", - "automatically_close_survey_after_n_seconds_if_no_response": "A felmérés automatikus bezárása másodperc elteltével az aktiválás után, amennyiben nem érkezik válasz.", + "automatically_close_survey_after_n_seconds_if_no_response": "Kérdőív automatikus lezárása az aktiváló utáni másodperc után, ha nincs válasz.", "automatically_close_the_survey_after_a_certain_number_of_responses": "A kérdőív automatikus lezárása egy bizonyos számú válasz után.", "automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "A kérdőív automatikus lezárása, ha a felhasználó nem válaszol egy bizonyos másodpercnyi idő után.", - "automatically_mark_complete_after_n_responses": "A felmérés automatikus teljesítettként való megjelölése kitöltött válasz után.", + "automatically_mark_complete_after_n_responses": "A kérdőív automatikus megjelölése befejezettként befejezett válasz után.", "back_button_label": "A „Vissza” gomb címkéje", "background_styling": "Háttér stílusának beállítása", "block_duplicated": "A blokk kettőzve.", @@ -2913,7 +2913,7 @@ "conditional_logic": "Feltételes logika", "confirm_default_language": "Alapértelmezett nyelv megerősítése", "confirm_survey_changes": "Kérdőív változtatásainak megerősítése", - "connect_formbricks_and_launch_surveys": "Csatlakoztassa a Formbricks-et, és indítson felméréseket webhelyén vagy alkalmazásában.", + "connect_formbricks_and_launch_surveys": "Kapcsolódás a Formbrickshez és kérdőívek indítása a webhelyén vagy az alkalmazásában.", "contact_fields": "Kapcsolatfelvételi mezők", "contains": "Tartalmazza", "continue_to_settings": "Folytatás a beállításokhoz", @@ -3072,7 +3072,7 @@ "last_name": "Vezetéknév", "let_people_upload_up_to_25_files_at_the_same_time": "Lehetővé tétel a személyek számára, hogy egyszerre legfeljebb 25 fájlt töltsenek fel.", "limit_the_maximum_file_size": "A legnagyobb fájlméret korlátozása a feltöltéseknél.", - "limit_upload_file_size_to_mb": "A feltölthető fájlméret korlátozása MB-ra", + "limit_upload_file_size_to_mb": "Feltöltési fájlméret korlátozása MB méretűre", "link_survey_description": "Egy kérdőív oldalára mutató hivatkozás megosztása vagy a kérdőív beágyazása egy weboldalba vagy e-mailbe.", "list": "Lista", "load_segment": "Szakasz betöltése", @@ -3087,9 +3087,9 @@ "matrix_all_fields": "Összes mező", "matrix_rows": "Sorok", "max_file_size": "Legnagyobb fájlméret", - "max_file_size_limit_is_mb": "A maximális fájlméret {{maxSize}} MB.", - "max_file_size_limit_is_mb_upgrade": "A maximális fájlméret {{maxSize}} MB. Amennyiben többre van szüksége, kérjük, frissítse csomagját.", - "missing_first": "Hiányzik az első", + "max_file_size_limit_is_mb": "A legnagyobb fájlméretkorlát {{maxSize}} MB.", + "max_file_size_limit_is_mb_upgrade": "A legnagyobb fájlméretkorlát {{maxSize}} MB. Ha többre van szüksége, akkor váltson magasabb csomagra.", + "missing_first": "Hiányzók először", "move_question_to_block": "Kérdés áthelyezése egy blokkba", "multiply": "Szorzás *", "needed_for_self_hosted_cal_com_instance": "Saját üzemeltetésű Cal.com-példányhoz szükséges", @@ -3211,7 +3211,7 @@ "select_type": "Típus kiválasztása", "send_survey_to_audience_who_match": "Kérdőív küldése az erre illeszkedő közönségnek…", "send_your_respondents_to_a_page_of_your_choice": "A válaszadók küldése a választási lehetőség oldalára.", - "set_global_placement_in_look_feel_settings_hint": "A felmérések elhelyezkedésének konzisztens megőrzése érdekében beállíthatja a globális elhelyezést a Megjelenés és Élmény beállításokban.", + "set_global_placement_in_look_feel_settings_hint": "Ahhoz, hogy következetesen megtartsa az elhelyezést az összes kérdőívnél, beállíthatja a globális elhelyezést a megjelenítési beállításokban.", "settings_saved_successfully": "A beállítások sikeresen elmentve", "seven_points": "7 pont", "show_block_settings": "Blokkbeállítások megjelenítése", @@ -3221,7 +3221,7 @@ "show_multiple_times": "Megjelenítés korlátozott számú alkalommal", "show_only_once": "Megjelenítés csak egyszer", "show_question_settings": "Kérdésbeállítások megjelenítése", - "show_survey_maximum_of_n_times": "A felmérés megjelenítése legfeljebb alkalommal.", + "show_survey_maximum_of_n_times": "Kérdőív megjelenítése legfeljebb alkalommal.", "show_survey_to_users": "Kérdőív megjelenítése a felhasználók ennyi százalékának", "show_to_x_percentage_of_targeted_users": "Megjelenítés a célzott felhasználók {percentage}%-ának", "shrink_preview": "Előnézet összecsukása", @@ -3337,8 +3337,8 @@ "visibility_and_recontact_description": "Annak vezérlése, hogy ez a kérdőív mikor jelenhet meg és milyen gyakran jelenhet meg újra.", "visible": "Látható", "wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Várakozás néhány másodpercig az aktiválás után, mielőtt megjelenítené a kérdőívet", - "wait_n_days_before_showing_this_survey_again": " vagy több nap eltelésének várakozása az utoljára megjelenített felmérés és ezen felmérés megjelenítése között.", - "wait_n_seconds_before_showing_the_survey": " másodperc várakozása a felmérés megjelenítése előtt.", + "wait_n_days_before_showing_this_survey_again": "Várakozás vagy több napig az utolsó megjelenített kérdőív és ezen kérdőív megjelenése között.", + "wait_n_seconds_before_showing_the_survey": "Várakozás másodpercig a kérdőív megjelenítése előtt.", "waiting_time_across_surveys": "Várakozási időszak (kérdőívek között)", "waiting_time_across_surveys_description": "A kérdőívekbe való belefáradás megakadályozásához válassza ki, hogy ez a kérdőív hogyan lép kölcsönhatásba a munkaterület-szintű várakozási időszakkal.", "welcome_message": "Üdvözlő üzenet", @@ -3402,10 +3402,10 @@ "search_by_survey_name": "Keresés kérdőívnév alapján", "share": { "anonymous_links": { - "custom_single_use_id_description": "Hozzon létre egy olvasható, egyszeri használatú azonosítót, és másoljon egy aláírt hivatkozást hozzá.", - "custom_single_use_id_placeholder": "CUSTOM-ID", - "custom_single_use_id_required": "Enter a custom single-use ID.", - "custom_single_use_id_title": "Egyedi, egyszeri használatú azonosító használata az URL-ben.", + "custom_single_use_id_description": "Egy olvasható, egyszer használatos azonosító létrehozása, és egy aláírt hivatkozás másolása hozzá.", + "custom_single_use_id_placeholder": "EGYÉNI-AZONOSÍTÓ", + "custom_single_use_id_required": "Egyéni, egyszer használatos azonosító megadása.", + "custom_single_use_id_title": "Egyéni, egyszer használatos azonosító használata az URL-ben.", "custom_start_point": "Egyéni kezdési pont", "data_prefilling": "Adatok előre kitöltése", "description": "Az ezekről a hivatkozásokról érkező válaszok névtelenek lesznek", diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/utils.mock.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/utils.mock.ts index 72cf372d27a5..03bf1a8de9eb 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/utils.mock.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/tests/__mocks__/utils.mock.ts @@ -26,7 +26,7 @@ export const fileUploadQuestion: Survey["questions"][number] = { export const responseData: Response["data"] = { [openTextQuestion.id]: "Open Text Answer", [fileUploadQuestion.id]: [ - `https://example.com/dummy/${workspaceId}/private/file1.png`, - `https://example.com/dummy/${workspaceId}/private/file2.pdf`, + `https://example.com/storage/${workspaceId}/private/file1.png`, + `https://example.com/storage/${workspaceId}/private/file2.pdf`, ], }; diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/utils.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/utils.ts index db4c0c7b0184..e64070a7d916 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/utils.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/utils.ts @@ -4,6 +4,7 @@ import { Result, okVoid } from "@formbricks/types/error-handlers"; import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { deleteFile } from "@/modules/storage/service"; +import { parseStorageFileUrl } from "@/modules/storage/utils"; export const findAndDeleteUploadedFilesInResponse = async ( responseData: Response["data"], @@ -24,13 +25,12 @@ export const findAndDeleteUploadedFilesInResponse = async ( const deletionPromises = fileUrls.map(async (fileUrl) => { try { - const { pathname } = new URL(fileUrl); - const [, storageId, accessType, fileName] = pathname.split("/").filter(Boolean); + const storageFile = parseStorageFileUrl(fileUrl); - if (!storageId || !accessType || !fileName) { - throw new Error(`Invalid file path: ${pathname}`); + if (!storageFile) { + throw new Error(`Invalid storage file URL: ${fileUrl}`); } - return deleteFile(storageId, accessType as "private" | "public", fileName, workspaceId); + return deleteFile(storageFile.storageId, storageFile.accessType, storageFile.fileName, workspaceId); } catch (error) { logger.error({ error, fileUrl }, "Failed to delete file"); } diff --git a/apps/web/modules/integrations/webhooks/actions.ts b/apps/web/modules/integrations/webhooks/actions.ts index 56c4108abcf1..ef2618306368 100644 --- a/apps/web/modules/integrations/webhooks/actions.ts +++ b/apps/web/modules/integrations/webhooks/actions.ts @@ -157,7 +157,7 @@ export const testEndpointAction = authenticatedActionClient }, { type: "workspaceTeam", - minPermission: "read", + minPermission: "readWrite", workspaceId: await getWorkspaceIdFromWebhookId(parsedInput.webhookId), }, ], diff --git a/apps/web/modules/integrations/webhooks/components/webhook-settings-tab.tsx b/apps/web/modules/integrations/webhooks/components/webhook-settings-tab.tsx index c071e936c22d..5d599b64803d 100644 --- a/apps/web/modules/integrations/webhooks/components/webhook-settings-tab.tsx +++ b/apps/web/modules/integrations/webhooks/components/webhook-settings-tab.tsx @@ -196,9 +196,11 @@ export const WebhookSettingsTab = ({ onChange={(e) => { setTestEndpointInput(e.target.value); }} - readOnly={webhook.source !== "user"} + readOnly={isReadOnly || webhook.source !== "user"} className={clsx( - webhook.source === "user" ? null : "cursor-not-allowed bg-slate-100 text-slate-500", + isReadOnly || webhook.source !== "user" + ? "cursor-not-allowed bg-slate-100 text-slate-500" + : null, endpointAccessible === true ? "border-green-500 bg-green-50" : endpointAccessible === false @@ -209,16 +211,18 @@ export const WebhookSettingsTab = ({ )} placeholder={t("workspace.integrations.webhooks.webhook_url_placeholder")} /> - + {!isReadOnly && ( + + )} diff --git a/apps/web/modules/storage/service.test.ts b/apps/web/modules/storage/service.test.ts index 43f74bf11561..ae0a9948a1d0 100644 --- a/apps/web/modules/storage/service.test.ts +++ b/apps/web/modules/storage/service.test.ts @@ -117,6 +117,61 @@ describe("storage service", () => { } }); + test("should generate scoped private upload URL when path segments are provided", 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", + "ws-123", + "application/pdf", + "private" as TAccessType, + 1024 * 1024 * 10, + ["surveys", "survey-123", "elements", "element-123"] + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.fileUrl).toBe( + `/storage/ws-123/private/surveys/survey-123/elements/element-123/test-doc--fid--${mockUUID}.pdf` + ); + } + + expect(getSignedUploadUrl).toHaveBeenCalledWith( + `test-doc--fid--${mockUUID}.pdf`, + "application/pdf", + "ws-123/private/surveys/survey-123/elements/element-123", + 1024 * 1024 * 10 + ); + }); + + test.each(["", ".", "..", "bad segment", "bad/segment", "bad\\segment", "bad?segment", "bad#segment"])( + "should reject unsafe scoped private upload path segment %s", + async (unsafeSegment) => { + const result = await getSignedUrlForUpload( + "test-doc.pdf", + "ws-123", + "application/pdf", + "private" as TAccessType, + 1024 * 1024 * 10, + ["surveys", unsafeSegment] + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(StorageErrorCode.InvalidInput); + } + expect(getSignedUploadUrl).not.toHaveBeenCalled(); + } + ); + test("should properly sanitize filenames with special characters like # in URL", async () => { const mockSignedUrlResponse = { ok: true, diff --git a/apps/web/modules/storage/service.ts b/apps/web/modules/storage/service.ts index 1a69a9cb676b..201fa7cd4c49 100644 --- a/apps/web/modules/storage/service.ts +++ b/apps/web/modules/storage/service.ts @@ -10,15 +10,18 @@ import { getSignedUploadUrl, } from "@formbricks/storage"; import { Result, err, ok } from "@formbricks/types/error-handlers"; -import { TAccessType } from "@formbricks/types/storage"; +import { type TAccessType } from "@formbricks/types/storage"; import { sanitizeFileName } from "./utils"; +const SAFE_FILE_PATH_SEGMENT = /^[A-Za-z0-9_-]+$/; + export const getSignedUrlForUpload = async ( fileName: string, workspaceId: string, fileType: string, accessType: TAccessType, - maxFileUploadSize: number = 1024 * 1024 * 10 // 10MB + maxFileUploadSize: number = 1024 * 1024 * 10, // 10MB + filePathSegments: string[] = [] ): Promise< Result< { @@ -34,17 +37,19 @@ export const getSignedUrlForUpload = async ( if (!safeFileName) { return err({ code: StorageErrorCode.InvalidInput }); } + + if (filePathSegments.some((segment) => !SAFE_FILE_PATH_SEGMENT.test(segment))) { + return err({ code: StorageErrorCode.InvalidInput }); + } + + const encodedFilePathSegments = filePathSegments.map((segment) => encodeURIComponent(segment)); const fileNameWithoutExtension = safeFileName.split(".").slice(0, -1).join("."); const fileExtension = safeFileName.split(".").pop(); const updatedFileName = `${fileNameWithoutExtension}--fid--${randomUUID()}.${fileExtension}`; + const filePath = [workspaceId, accessType, ...filePathSegments].join("/"); - const signedUrlResult = await getSignedUploadUrl( - updatedFileName, - fileType, - `${workspaceId}/${accessType}`, - maxFileUploadSize - ); + const signedUrlResult = await getSignedUploadUrl(updatedFileName, fileType, filePath, maxFileUploadSize); if (!signedUrlResult.ok) { return signedUrlResult; @@ -54,7 +59,10 @@ export const getSignedUrlForUpload = async ( return ok({ signedUrl: signedUrlResult.data.signedUrl, presignedFields: signedUrlResult.data.presignedFields, - fileUrl: `/storage/${workspaceId}/${accessType}/${encodeURIComponent(updatedFileName)}`, + fileUrl: `/storage/${workspaceId}/${accessType}/${[ + ...encodedFilePathSegments, + encodeURIComponent(updatedFileName), + ].join("/")}`, }); } catch (error) { logger.error({ error }, "Error getting signed url for upload"); diff --git a/apps/web/modules/storage/utils.test.ts b/apps/web/modules/storage/utils.test.ts index 7b3fc17fdea1..b4c9fe5d144c 100644 --- a/apps/web/modules/storage/utils.test.ts +++ b/apps/web/modules/storage/utils.test.ts @@ -7,10 +7,12 @@ import { TSurveyQuestion } from "@formbricks/types/surveys/types"; import { isAllowedFileExtension, isValidImageFile, + parseStorageFileUrl, resolveStorageUrl, resolveStorageUrlAuto, resolveStorageUrlsInObject, sanitizeFileName, + validateClientFileUploads, validateFileUploads, validateSingleFile, validateSurveyAllowsFileUpload, @@ -369,7 +371,11 @@ describe("storage utils", () => { }, ] as unknown as TSurveyBlock[]; - expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true }); + expect( + validateSurveyAllowsFileUpload({ fileName: "report.pdf", elementId: "element1", blocks }) + ).toEqual({ + ok: true, + }); }); test("should allow a matching extension from a legacy file upload question", () => { @@ -381,7 +387,9 @@ describe("storage utils", () => { }, ] as TSurveyQuestion[]; - expect(validateSurveyAllowsFileUpload({ fileName: "image.png", questions })).toEqual({ ok: true }); + expect( + validateSurveyAllowsFileUpload({ fileName: "image.png", elementId: "question1", questions }) + ).toEqual({ ok: true }); }); test("should allow any globally safe extension when a file upload has no survey restriction", () => { @@ -398,7 +406,11 @@ describe("storage utils", () => { }, ] as unknown as TSurveyBlock[]; - expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true }); + expect( + validateSurveyAllowsFileUpload({ fileName: "report.pdf", elementId: "element1", blocks }) + ).toEqual({ + ok: true, + }); }); test("should reject surveys without file upload blocks or questions", () => { @@ -421,9 +433,11 @@ describe("storage utils", () => { }, ] as TSurveyQuestion[]; - expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks, questions })).toEqual({ + expect( + validateSurveyAllowsFileUpload({ fileName: "report.pdf", elementId: "question1", blocks, questions }) + ).toEqual({ ok: false, - reason: "no_file_upload_question", + reason: "no_file_upload_element", }); }); @@ -447,7 +461,9 @@ describe("storage utils", () => { }, ] as unknown as TSurveyBlock[]; - expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ + expect( + validateSurveyAllowsFileUpload({ fileName: "report.pdf", elementId: "element2", blocks }) + ).toEqual({ ok: false, reason: "file_extension_not_allowed", }); @@ -473,7 +489,11 @@ describe("storage utils", () => { }, ] as unknown as TSurveyBlock[]; - expect(validateSurveyAllowsFileUpload({ fileName: "report.pdf", blocks })).toEqual({ ok: true }); + expect( + validateSurveyAllowsFileUpload({ fileName: "report.pdf", elementId: "element2", blocks }) + ).toEqual({ + ok: true, + }); }); test("should reject files without a globally safe extension even when the survey has an unrestricted upload", () => { @@ -484,15 +504,148 @@ describe("storage utils", () => { }, ] as TSurveyQuestion[]; - expect(validateSurveyAllowsFileUpload({ fileName: "report", questions })).toEqual({ + expect( + validateSurveyAllowsFileUpload({ fileName: "report", elementId: "question1", questions }) + ).toEqual({ ok: false, reason: "file_extension_not_allowed", }); - expect(validateSurveyAllowsFileUpload({ fileName: "malware.exe", questions })).toEqual({ + expect( + validateSurveyAllowsFileUpload({ fileName: "malware.exe", elementId: "question1", questions }) + ).toEqual({ ok: false, reason: "file_extension_not_allowed", }); }); + + test("should reject an element id that is not the file upload element", () => { + const blocks = [ + { + id: "block1", + name: "Block 1", + elements: [ + { + id: "element1", + type: "fileUpload" as const, + allowedFileExtensions: ["pdf"], + }, + ], + }, + ] as unknown as TSurveyBlock[]; + + expect( + validateSurveyAllowsFileUpload({ fileName: "report.pdf", elementId: "element2", blocks }) + ).toEqual({ + ok: false, + reason: "file_upload_element_not_found", + }); + }); + }); + + describe("validateClientFileUploads", () => { + const workspaceId = "clxworkspace123"; + const surveyId = "clxsurvey123"; + const elementId = "file_element"; + const blocks = [ + { + id: "block1", + name: "Block 1", + elements: [ + { + id: elementId, + type: "fileUpload" as const, + allowedFileExtensions: ["pdf"], + }, + ], + }, + ] as unknown as TSurveyBlock[]; + + test("should accept scoped private storage URLs for the matching survey and element", () => { + const responseData = { + [elementId]: [ + `/storage/${workspaceId}/private/surveys/${surveyId}/elements/${elementId}/report--fid--abc.pdf`, + ], + }; + + expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(true); + }); + + test("should reject unscoped legacy storage URLs for new client submissions", () => { + const responseData = { + [elementId]: [`/storage/${workspaceId}/private/report--fid--abc.pdf`], + }; + + expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false); + }); + + test("should reject scoped URLs for a different survey", () => { + const responseData = { + [elementId]: [ + `/storage/${workspaceId}/private/surveys/otherSurvey/elements/${elementId}/report--fid--abc.pdf`, + ], + }; + + expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false); + }); + + test("should reject scoped URLs for a different element", () => { + const responseData = { + [elementId]: [ + `/storage/${workspaceId}/private/surveys/${surveyId}/elements/otherElement/report--fid--abc.pdf`, + ], + }; + + expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false); + }); + + test("should reject external URLs", () => { + const responseData = { + [elementId]: ["https://example.com/report--fid--abc.pdf"], + }; + + expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false); + }); + + test("should reject file extensions not allowed by the matching upload element", () => { + const responseData = { + [elementId]: [ + `/storage/${workspaceId}/private/surveys/${surveyId}/elements/${elementId}/image--fid--abc.png`, + ], + }; + + expect(validateClientFileUploads({ data: responseData, workspaceId, surveyId, blocks })).toBe(false); + }); + }); + + describe("parseStorageFileUrl", () => { + test("should parse nested relative storage URLs", () => { + expect( + parseStorageFileUrl( + "/storage/workspace-123/private/surveys/survey-123/elements/element-123/report.pdf" + ) + ).toEqual({ + storageId: "workspace-123", + accessType: "private", + fileName: "surveys/survey-123/elements/element-123/report.pdf", + }); + }); + + test("should parse absolute storage URLs", () => { + expect(parseStorageFileUrl("https://example.com/storage/workspace-123/public/report.pdf")).toEqual({ + storageId: "workspace-123", + accessType: "public", + fileName: "report.pdf", + }); + }); + + test.each([ + "https://example.com/not-storage/workspace-123/private/report.pdf", + "/storage/workspace-123/internal/report.pdf", + "/storage/workspace-123/private", + "not a url", + ])("should reject invalid storage URL %s", (fileUrl) => { + expect(parseStorageFileUrl(fileUrl)).toBeNull(); + }); }); describe("isValidImageFile", () => { diff --git a/apps/web/modules/storage/utils.ts b/apps/web/modules/storage/utils.ts index 8cdffef4e11f..b9e4e9d1c417 100644 --- a/apps/web/modules/storage/utils.ts +++ b/apps/web/modules/storage/utils.ts @@ -1,7 +1,11 @@ import "server-only"; import { type StorageError, StorageErrorCode } from "@formbricks/storage"; import { TResponseData } from "@formbricks/types/responses"; -import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/storage"; +import { + type TAccessType, + type TAllowedFileExtension, + ZAllowedFileExtension, +} from "@formbricks/types/storage"; import { TSurveyBlock } from "@formbricks/types/surveys/blocks"; import { TSurveyElementTypeEnum, TSurveyFileUploadElement } from "@formbricks/types/surveys/elements"; import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; @@ -120,7 +124,7 @@ export type TSurveyFileUploadPermissionResult = } | { ok: false; - reason: "no_file_upload_question" | "file_extension_not_allowed"; + reason: "no_file_upload_element" | "file_upload_element_not_found" | "file_extension_not_allowed"; }; const getAllowedFileExtensionFromFileName = (fileName: string): TAllowedFileExtension | null => { @@ -132,26 +136,47 @@ const getAllowedFileExtensionFromFileName = (fileName: string): TAllowedFileExte return extensionValidation.success ? extensionValidation.data : null; }; -export const validateSurveyAllowsFileUpload = ({ - fileName, +const getSurveyFileUploadConfigs = ({ blocks, questions, }: { - fileName: string; blocks?: TSurveyBlock[] | null; questions?: TSurveyQuestion[] | null; -}): TSurveyFileUploadPermissionResult => { - const fileUploadConfigs = [ +}): TSurveyFileUploadElement[] => { + return [ ...(blocks ?? []) .flatMap((block) => block.elements) .filter((element) => element.type === TSurveyElementTypeEnum.FileUpload), ...(questions ?? []).filter((question) => question.type === TSurveyQuestionTypeEnum.FileUpload), ] as TSurveyFileUploadElement[]; +}; + +export const validateSurveyAllowsFileUpload = ({ + fileName, + elementId, + blocks, + questions, +}: { + fileName: string; + elementId: string; + blocks?: TSurveyBlock[] | null; + questions?: TSurveyQuestion[] | null; +}): TSurveyFileUploadPermissionResult => { + const fileUploadConfigs = getSurveyFileUploadConfigs({ blocks, questions }); if (fileUploadConfigs.length === 0) { return { ok: false, - reason: "no_file_upload_question", + reason: "no_file_upload_element", + }; + } + + const fileUploadConfig = fileUploadConfigs.find((config) => config.id === elementId); + + if (!fileUploadConfig) { + return { + ok: false, + reason: "file_upload_element_not_found", }; } @@ -164,11 +189,9 @@ export const validateSurveyAllowsFileUpload = ({ }; } - const isFileExtensionAllowed = fileUploadConfigs.some((fileUploadConfig) => { - const { allowedFileExtensions } = fileUploadConfig; - - return allowedFileExtensions === undefined || allowedFileExtensions.includes(fileExtension); - }); + const { allowedFileExtensions } = fileUploadConfig; + const isFileExtensionAllowed = + allowedFileExtensions === undefined || allowedFileExtensions.includes(fileExtension); return isFileExtensionAllowed ? { ok: true } @@ -178,6 +201,127 @@ export const validateSurveyAllowsFileUpload = ({ }; }; +const getStorageUrlPathSegments = (fileUrl: string): string[] | null => { + if (!fileUrl.startsWith("/storage/")) return null; + + const pathWithoutSearch = fileUrl.split(/[?#]/)[0]; + return pathWithoutSearch.split("/").filter(Boolean); +}; + +type TParsedStorageFileUrl = { + storageId: string; + accessType: TAccessType; + fileName: string; +}; + +export const parseStorageFileUrl = (fileUrl: string): TParsedStorageFileUrl | null => { + let pathname: string; + + try { + pathname = fileUrl.startsWith("/storage/") ? fileUrl : new URL(fileUrl).pathname; + } catch { + return null; + } + + const pathWithoutSearch = pathname.split(/[?#]/)[0]; + if (!pathWithoutSearch.startsWith("/storage/")) return null; + + const [storageSegment, storageId, accessType, ...fileNameSegments] = pathWithoutSearch + .split("/") + .filter(Boolean); + const fileName = fileNameSegments.join("/"); + + if ( + storageSegment !== "storage" || + !storageId || + !fileName || + (accessType !== "private" && accessType !== "public") + ) { + return null; + } + + return { storageId, accessType, fileName }; +}; + +const isScopedPrivateUploadUrl = ({ + fileUrl, + workspaceId, + surveyId, + elementId, +}: { + fileUrl: string; + workspaceId: string; + surveyId: string; + elementId: string; +}): boolean => { + const segments = getStorageUrlPathSegments(fileUrl); + + if (segments?.length !== 8) return false; + + const [ + storageSegment, + storageWorkspaceId, + accessType, + surveysSegment, + storageSurveyId, + elementsSegment, + storageElementId, + fileName, + ] = segments; + + return ( + storageSegment === "storage" && + storageWorkspaceId === workspaceId && + accessType === "private" && + surveysSegment === "surveys" && + storageSurveyId === surveyId && + elementsSegment === "elements" && + storageElementId === elementId && + Boolean(fileName) + ); +}; + +export const validateClientFileUploads = ({ + data, + workspaceId, + surveyId, + blocks, + questions, +}: { + data?: TResponseData; + workspaceId: string; + surveyId: string; + blocks?: TSurveyBlock[] | null; + questions?: TSurveyQuestion[] | null; +}): boolean => { + if (!data) return true; + + const fileUploadConfigs = getSurveyFileUploadConfigs({ blocks, questions }); + + for (const fileUploadConfig of fileUploadConfigs) { + const fileUrls = data[fileUploadConfig.id]; + + if (fileUrls === undefined) continue; + if (!Array.isArray(fileUrls) || !fileUrls.every((url) => typeof url === "string")) return false; + + for (const fileUrl of fileUrls) { + if (!validateSingleFile(fileUrl, fileUploadConfig.allowedFileExtensions)) return false; + if ( + !isScopedPrivateUploadUrl({ + fileUrl, + workspaceId, + surveyId, + elementId: fileUploadConfig.id, + }) + ) { + return false; + } + } + } + + return true; +}; + export const isValidImageFile = (fileUrl: string): boolean => { const fileName = getOriginalFileNameFromUrl(fileUrl); if (!fileName || fileName.endsWith(".")) return false; diff --git a/apps/web/playwright/utils/helper.ts b/apps/web/playwright/utils/helper.ts index f63677db3b5a..837b95c7d9d6 100644 --- a/apps/web/playwright/utils/helper.ts +++ b/apps/web/playwright/utils/helper.ts @@ -7,7 +7,6 @@ import { TWorkspaceConfigChannel } from "@formbricks/types/workspace"; import { CreateSurveyParams, CreateSurveyWithLogicParams } from "@/playwright/utils/mock"; const MOCK_STORAGE_UPLOAD_PATH = "/__playwright__/mock-storage-upload"; -const MOCK_STORAGE_FILE_PATH = "/storage/playwright-mock"; type MockStorageFileFixture = { name: string; @@ -44,11 +43,19 @@ const DEFAULT_MOCK_STORAGE_FILE_FIXTURE: MockStorageFileFixture = { ), }; -const getMockStorageFileUrl = ( - appOrigin: string, - fileName: string, - accessType: "public" | "private" -): string => { +const getMockStorageFileUrl = ({ + appOrigin, + fileName, + accessType, + storageId = "playwright-mock", + filePathSegments = [], +}: { + appOrigin: string; + fileName: string; + accessType: "public" | "private"; + storageId?: string; + filePathSegments?: string[]; +}): string => { if (accessType === "public") { const fixture = PLAYWRIGHT_STORAGE_FILE_FIXTURES.get(fileName); @@ -57,7 +64,7 @@ const getMockStorageFileUrl = ( } } - return `${MOCK_STORAGE_FILE_PATH}/${accessType}/${encodeURIComponent(fileName)}`; + return `/storage/${storageId}/${accessType}/${[...filePathSegments, encodeURIComponent(fileName)].join("/")}`; }; /** @@ -86,7 +93,7 @@ export const mockStorageUploads = async (page: Page): Promise => { presignedFields: { key: fileName, }, - fileUrl: getMockStorageFileUrl(appOrigin, fileName, "public"), + fileUrl: getMockStorageFileUrl({ appOrigin, fileName, accessType: "public" }), signingData: null, updatedFileName: fileName, }, @@ -113,9 +120,17 @@ export const mockStorageUploads = async (page: Page): Promise => { return; } - const payload = route.request().postDataJSON() as { fileName?: string } | undefined; + const payload = route.request().postDataJSON() as + | { fileName?: string; surveyId?: string; elementId?: string } + | undefined; const fileName = payload?.fileName ?? "uploaded-file.bin"; - const appOrigin = new URL(route.request().url()).origin; + const requestUrl = new URL(route.request().url()); + const appOrigin = requestUrl.origin; + const workspaceId = requestUrl.pathname.split("/").filter(Boolean)[3] ?? "playwright-mock"; + const filePathSegments = + payload?.surveyId && payload?.elementId + ? ["surveys", payload.surveyId, "elements", payload.elementId] + : []; await route.fulfill({ status: 200, @@ -126,7 +141,13 @@ export const mockStorageUploads = async (page: Page): Promise => { presignedFields: { key: fileName, }, - fileUrl: getMockStorageFileUrl(appOrigin, fileName, "private"), + fileUrl: getMockStorageFileUrl({ + appOrigin, + fileName, + accessType: "private", + storageId: workspaceId, + filePathSegments, + }), signingData: null, updatedFileName: fileName, }, @@ -148,7 +169,7 @@ export const mockStorageUploads = async (page: Page): Promise => { }); }); - await page.route(`**${MOCK_STORAGE_FILE_PATH}/**`, async (route) => { + await page.route("**/storage/**", async (route) => { if (!["GET", "HEAD"].includes(route.request().method())) { await route.fallback(); return; diff --git a/packages/js-core/src/types/storage.ts b/packages/js-core/src/types/storage.ts index 362f9b005350..d52324d55f95 100644 --- a/packages/js-core/src/types/storage.ts +++ b/packages/js-core/src/types/storage.ts @@ -1,6 +1,7 @@ export interface TUploadFileConfig { - allowedFileExtensions?: string[] | undefined; - surveyId?: string | undefined; + allowedFileExtensions?: string[]; + surveyId?: string; + elementId?: string; } export interface TUploadFileResponse { @@ -13,7 +14,7 @@ export interface TUploadFileResponse { uuid: string; } | null; updatedFileName: string; - presignedFields?: Record | undefined; + presignedFields?: Record; }; } diff --git a/packages/surveys/locales/hu.json b/packages/surveys/locales/hu.json index 1a58406cfcc2..819791295ef1 100644 --- a/packages/surveys/locales/hu.json +++ b/packages/surveys/locales/hu.json @@ -27,7 +27,7 @@ "select_option": "Lehetőség kiválasztása", "select_options": "Lehetőségek kiválasztása", "sending_responses": "Válaszok küldése…", - "survey_dialog": "Kérdőív párbeszédpanel", + "survey_dialog": "Kérdőív párbeszédablak", "takes_less_than_x_minutes": "{count, plural, one {Kevesebb mint 1 percet vesz igénybe} other {Kevesebb mint {count} percet vesz igénybe}}", "takes_x_minutes": "{count, plural, one {1 percet vesz igénybe} other {{count} percet vesz igénybe}}", "takes_x_plus_minutes": "{count}+ percet vesz igénybe", @@ -46,12 +46,12 @@ "duplicate_files": "A következő fájlok már fel lettek töltve: {duplicateNames}. Kettőzött fájlok nem engedélyezettek.", "file_size_exceeded": "A következő fájlok túllépik a legnagyobb, {maxSizeInMB} MB-os méretet, ezért eltávolításra kerültek: {fileNames}", "file_size_exceeded_alert": "A fájlnak kisebbnek kell lennie mint {maxSizeInMB} MB", - "invalid_file_name": "A fájlnév érvénytelen karaktereket tartalmaz. Nevezd át a fájlt, majd próbáld újra.", + "invalid_file_name": "A fájlnév érvénytelen karaktereket tartalmaz. Nevezze át a fájlt, és próbálja meg újra.", "no_valid_file_types_selected": "Nincs érvényes fájltípus kiválasztva. Válasszon egy érvényes fájltípust.", "only_one_file_can_be_uploaded_at_a_time": "Egyszerre csak egy fájl tölthető fel.", "placeholder_text": "Kattintson vagy húzza ide a fájlok feltöltéséhez", "upload_failed": "A feltöltés nem sikerült! Próbálja meg újra.", - "upload_service_unavailable": "A fájlfeltöltési szolgáltatás nem érhető el. Próbáld újra később, vagy vedd fel a kapcsolatot a felmérés tulajdonosával.", + "upload_service_unavailable": "A fájlfeltöltési szolgáltatás nem érhető el. Próbálja meg később újra, vagy vegye fel a kapcsolatot a kérdőív tulajdonosával.", "uploading": "Feltöltés…", "you_can_only_upload_a_maximum_of_files": "Legfeljebb csak {FILE_LIMIT} fájlt tölthet fel." }, diff --git a/packages/surveys/src/components/elements/file-upload-element.tsx b/packages/surveys/src/components/elements/file-upload-element.tsx index 16ac362e1a89..55bcd7787062 100644 --- a/packages/surveys/src/components/elements/file-upload-element.tsx +++ b/packages/surveys/src/components/elements/file-upload-element.tsx @@ -259,6 +259,7 @@ export function FileUploadElement({ { allowedFileExtensions: element.allowedFileExtensions, surveyId, + elementId: element.id, } ); return { name: file.name, url: uploadedUrl }; diff --git a/packages/surveys/src/lib/api-client.test.ts b/packages/surveys/src/lib/api-client.test.ts index e1759a9ee192..89a0397494a8 100644 --- a/packages/surveys/src/lib/api-client.test.ts +++ b/packages/surveys/src/lib/api-client.test.ts @@ -183,6 +183,45 @@ describe("ApiClient", () => { expect(fileUrl).toBe("https://fake-file-url.com"); }); + test("includes surveyId and elementId in the upload signing request", async () => { + vi.mocked(global.fetch) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + signedUrl: "https://fake-s3-url.com", + fileUrl: "/storage/ws-test/private/surveys/survey123/elements/element123/test.jpg", + presignedFields: { policy: "test" }, + signingData: null, + updatedFileName: "test.jpg", + }, + }), + } as unknown as Response) + .mockResolvedValueOnce({ ok: true } as unknown as Response); + + await client.uploadFile( + { + base64: "data:image/jpeg;base64,abcd", + name: "test.jpg", + type: "image/jpeg", + }, + { + allowedFileExtensions: ["jpg"], + surveyId: "survey123", + elementId: "element123", + } + ); + + const requestInit = vi.mocked(global.fetch).mock.calls[0][1] as RequestInit; + expect(JSON.parse(requestInit.body as string)).toEqual({ + fileName: "test.jpg", + fileType: "image/jpeg", + allowedFileExtensions: ["jpg"], + surveyId: "survey123", + elementId: "element123", + }); + }); + test("throws an error if file is invalid", async () => { await expect(() => client.uploadFile({ base64: "", name: "", type: "" } as any)).rejects.toThrow( "Invalid file object" diff --git a/packages/surveys/src/lib/api-client.ts b/packages/surveys/src/lib/api-client.ts index cfe1d396916f..ad2b4bccb2ea 100644 --- a/packages/surveys/src/lib/api-client.ts +++ b/packages/surveys/src/lib/api-client.ts @@ -116,7 +116,7 @@ export class ApiClient { name: string; base64: string; }, - { allowedFileExtensions, surveyId }: TUploadFileConfig | undefined = {} + { allowedFileExtensions, surveyId, elementId }: TUploadFileConfig | undefined = {} ): Promise { if (!file.name || !file.type || !file.base64) { throw new Error(`Invalid file object`); @@ -127,6 +127,7 @@ export class ApiClient { fileType: file.type, allowedFileExtensions, surveyId, + elementId, }; const response = await fetch(`${this.appUrl}/api/v1/client/${this.workspaceId}/storage`, { diff --git a/packages/types/storage.ts b/packages/types/storage.ts index 8d08c5c0f567..e99a758e5e65 100644 --- a/packages/types/storage.ts +++ b/packages/types/storage.ts @@ -114,6 +114,7 @@ export const ZDeleteFileRequest = ZDownloadFileRequest; export const ZUploadFileConfig = z.object({ allowedFileExtensions: z.array(z.string()).optional(), surveyId: z.string().optional(), + elementId: z.string().optional(), }); export type TUploadFileConfig = z.infer; @@ -124,6 +125,14 @@ export const ZUploadPrivateFileRequest = z fileType: z.string().trim().min(1), allowedFileExtensions: z.array(ZAllowedFileExtension).optional(), surveyId: z.cuid2(), + elementId: z + .string() + .trim() + .min(1) + .regex( + /^[a-zA-Z0-9_-]+$/, + "Element id must contain only alphanumeric characters, hyphens, or underscores" + ), workspaceId: z.cuid2(), }) .superRefine((data, ctx) => {