diff --git a/apps/web/app/api/(internal)/pipeline/lib/handleIntegrations.ts b/apps/web/app/api/(internal)/pipeline/lib/handleIntegrations.ts index 97f36765c573..a0617e07cf99 100644 --- a/apps/web/app/api/(internal)/pipeline/lib/handleIntegrations.ts +++ b/apps/web/app/api/(internal)/pipeline/lib/handleIntegrations.ts @@ -21,6 +21,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils"; import { getFormattedDateTimeString } from "@/lib/utils/datetime"; import { parseRecallInfo } from "@/lib/utils/recall"; import { truncateText } from "@/lib/utils/strings"; +import { resolveStorageUrlAuto } from "@/modules/storage/utils"; const convertMetaObjectToString = (metadata: TResponseMeta): string => { let result: string[] = []; @@ -256,10 +257,16 @@ const processElementResponse = ( const selectedChoiceIds = responseValue as string[]; return element.choices .filter((choice) => selectedChoiceIds.includes(choice.id)) - .map((choice) => choice.imageUrl) + .map((choice) => resolveStorageUrlAuto(choice.imageUrl)) .join("\n"); } + if (element.type === TSurveyElementTypeEnum.FileUpload && Array.isArray(responseValue)) { + return responseValue + .map((url) => (typeof url === "string" ? resolveStorageUrlAuto(url) : url)) + .join("; "); + } + return processResponseData(responseValue); }; @@ -368,7 +375,7 @@ const buildNotionPayloadProperties = ( responses[resp] = (pictureElement as any)?.choices .filter((choice) => selectedChoiceIds.includes(choice.id)) - .map((choice) => choice.imageUrl); + .map((choice) => resolveStorageUrlAuto(choice.imageUrl)); } }); diff --git a/apps/web/app/api/(internal)/pipeline/route.ts b/apps/web/app/api/(internal)/pipeline/route.ts index d43caa625e41..995b6aca4992 100644 --- a/apps/web/app/api/(internal)/pipeline/route.ts +++ b/apps/web/app/api/(internal)/pipeline/route.ts @@ -18,6 +18,7 @@ import { convertDatesInObject } from "@/lib/time"; import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler"; import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; import { sendResponseFinishedEmail } from "@/modules/email"; +import { resolveStorageUrlsInObject } from "@/modules/storage/utils"; import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups"; import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up"; import { handleIntegrations } from "./lib/handleIntegrations"; @@ -95,12 +96,15 @@ export const POST = async (request: Request) => { ]); }; + const resolvedResponseData = resolveStorageUrlsInObject(response.data); + const webhookPromises = webhooks.map((webhook) => { const body = JSON.stringify({ webhookId: webhook.id, event, data: { ...response, + data: resolvedResponseData, survey: { title: survey.name, type: survey.type, diff --git a/apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts b/apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts index e88afe914168..668cddc1d5d5 100644 --- a/apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts +++ b/apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts @@ -10,6 +10,7 @@ import { TJsEnvironmentStateSurvey, } from "@formbricks/types/js"; import { validateInputs } from "@/lib/utils/validate"; +import { resolveStorageUrlsInObject } from "@/modules/storage/utils"; import { transformPrismaSurvey } from "@/modules/survey/lib/utils"; /** @@ -177,14 +178,14 @@ export const getEnvironmentStateData = async (environmentId: string): Promise ({ ...r, data: resolveStorageUrlsInObject(r.data) })) + ), }; } catch (error) { if (error instanceof DatabaseError) { diff --git a/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts b/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts index 4bef5f3339e4..199d2e88e29b 100644 --- a/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts +++ b/apps/web/app/api/v1/management/surveys/[surveyId]/route.ts @@ -16,6 +16,7 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { resolveStorageUrlsInObject } from "@/modules/storage/utils"; const fetchAndAuthorizeSurvey = async ( surveyId: string, @@ -58,16 +59,18 @@ export const GET = withV1ApiWrapper({ if (shouldTransformToQuestions) { return { - response: responses.successResponse({ - ...result.survey, - questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings), - blocks: [], - }), + response: responses.successResponse( + resolveStorageUrlsInObject({ + ...result.survey, + questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings), + blocks: [], + }) + ), }; } return { - response: responses.successResponse(result.survey), + response: responses.successResponse(resolveStorageUrlsInObject(result.survey)), }; } catch (error) { return { @@ -202,12 +205,12 @@ export const PUT = withV1ApiWrapper({ }; return { - response: responses.successResponse(surveyWithQuestions), + response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)), }; } return { - response: responses.successResponse(updatedSurvey), + response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)), }; } catch (error) { return { diff --git a/apps/web/app/api/v1/management/surveys/route.ts b/apps/web/app/api/v1/management/surveys/route.ts index 225bdad0dab4..573ae745824b 100644 --- a/apps/web/app/api/v1/management/surveys/route.ts +++ b/apps/web/app/api/v1/management/surveys/route.ts @@ -14,6 +14,7 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { createSurvey } from "@/lib/survey/service"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; +import { resolveStorageUrlsInObject } from "@/modules/storage/utils"; import { getSurveys } from "./lib/surveys"; export const GET = withV1ApiWrapper({ @@ -55,7 +56,7 @@ export const GET = withV1ApiWrapper({ }); return { - response: responses.successResponse(surveysWithQuestions), + response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)), }; } catch (error) { if (error instanceof DatabaseError) { diff --git a/apps/web/lib/response/service.ts b/apps/web/lib/response/service.ts index 4522ad63111a..a4b1d82fdc0e 100644 --- a/apps/web/lib/response/service.ts +++ b/apps/web/lib/response/service.ts @@ -22,6 +22,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 { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization"; import { getOrganizationBilling } from "@/modules/survey/lib/survey"; import { ITEMS_PER_PAGE } from "../constants"; @@ -408,9 +409,10 @@ export const getResponseDownloadFile = async ( if (survey.isVerifyEmailEnabled) { headers.push("Verified Email"); } + const resolvedResponses = responses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) })); const jsonData = getResponsesJson( survey, - responses, + resolvedResponses, elements, userAttributes, hiddenFields, diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts index 30f3f5815611..af2722b263fc 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts @@ -15,7 +15,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 { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils"; import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses"; export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) => @@ -51,7 +51,10 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI return handleApiError(request, response.error as ApiErrorResponseV2); } - return responses.successResponse(response); + return responses.successResponse({ + ...response, + data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) }, + }); }, }); @@ -243,7 +246,10 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str auditLog.newObject = response.data; } - return responses.successResponse(response); + return responses.successResponse({ + ...response, + data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) }, + }); }, action: "updated", targetType: "response", diff --git a/apps/web/modules/api/v2/management/responses/route.ts b/apps/web/modules/api/v2/management/responses/route.ts index 1966271ccf2e..84f5a3c31970 100644 --- a/apps/web/modules/api/v2/management/responses/route.ts +++ b/apps/web/modules/api/v2/management/responses/route.ts @@ -12,7 +12,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 { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils"; import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response"; export const GET = async (request: NextRequest) => @@ -44,7 +44,9 @@ export const GET = async (request: NextRequest) => environmentResponses.push(...res.data.data); - return responses.successResponse({ data: environmentResponses }); + return responses.successResponse({ + data: environmentResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) })), + }); }, }); diff --git a/apps/web/modules/storage/utils.test.ts b/apps/web/modules/storage/utils.test.ts index bbaeb26fc1c9..3a26419b3ba4 100644 --- a/apps/web/modules/storage/utils.test.ts +++ b/apps/web/modules/storage/utils.test.ts @@ -8,6 +8,8 @@ import { isValidFileTypeForExtension, isValidImageFile, resolveStorageUrl, + resolveStorageUrlAuto, + resolveStorageUrlsInObject, sanitizeFileName, validateFileUploads, validateSingleFile, @@ -406,7 +408,7 @@ describe("storage utils", () => { expect(resolveStorageUrl("")).toBe(""); }); - test("should return absolute URL unchanged (backward compatibility)", () => { + test("should return absolute URL unchanged", () => { const httpsUrl = "https://example.com/storage/env-123/public/image.jpg"; const httpUrl = "http://example.com/storage/env-123/public/image.jpg"; @@ -415,14 +417,12 @@ describe("storage utils", () => { }); test("should resolve relative /storage/ path to absolute URL", async () => { - // Use actual implementation with mocked dependencies const { resolveStorageUrl: actualResolveStorageUrl } = await vi.importActual("@/modules/storage/utils"); const relativePath = "/storage/env-123/public/image.jpg"; const result = actualResolveStorageUrl(relativePath); - // Should prepend the base URL (from mocked WEBAPP_URL or getPublicDomain) expect(result).toContain("/storage/env-123/public/image.jpg"); expect(result.startsWith("http")).toBe(true); }); @@ -432,4 +432,209 @@ describe("storage utils", () => { expect(resolveStorageUrl("relative/path.jpg")).toBe("relative/path.jpg"); }); }); + + describe("resolveStorageUrlAuto", () => { + test("should return non-storage strings unchanged", () => { + expect(resolveStorageUrlAuto("hello world")).toBe("hello world"); + expect(resolveStorageUrlAuto("/some/other/path")).toBe("/some/other/path"); + expect(resolveStorageUrlAuto("https://example.com/image.jpg")).toBe("https://example.com/image.jpg"); + }); + + test("should NOT transform free-text values that merely start with /storage/", () => { + expect(resolveStorageUrlAuto("/storage/help")).toBe("/storage/help"); + expect(resolveStorageUrlAuto("/storage/")).toBe("/storage/"); + expect(resolveStorageUrlAuto("/storage/some-text")).toBe("/storage/some-text"); + expect(resolveStorageUrlAuto("/storage/foo/bar")).toBe("/storage/foo/bar"); + }); + + test("should resolve public storage URL", async () => { + const { resolveStorageUrlAuto: actual } = + await vi.importActual("@/modules/storage/utils"); + + const result = actual("/storage/env-123/public/image.jpg"); + expect(result).toContain("/storage/env-123/public/image.jpg"); + expect(result.startsWith("http")).toBe(true); + }); + + test("should detect private access type from URL path", () => { + const privateUrl = "/storage/env-123/private/file.pdf"; + const publicUrl = "/storage/env-123/public/image.jpg"; + + expect(privateUrl.includes("/private/")).toBe(true); + expect(publicUrl.includes("/private/")).toBe(false); + }); + }); + + describe("resolveStorageUrlsInObject", () => { + test("should return null and undefined as-is", () => { + expect(resolveStorageUrlsInObject(null)).toBeNull(); + expect(resolveStorageUrlsInObject(undefined)).toBeUndefined(); + }); + + test("should return primitive values unchanged", () => { + expect(resolveStorageUrlsInObject(42)).toBe(42); + expect(resolveStorageUrlsInObject(true)).toBe(true); + expect(resolveStorageUrlsInObject("hello")).toBe("hello"); + }); + + test("should NOT transform free-text that merely starts with /storage/", () => { + expect(resolveStorageUrlsInObject("/storage/help")).toBe("/storage/help"); + expect(resolveStorageUrlsInObject("/storage/")).toBe("/storage/"); + + const input = { + questionId1: "/storage/", + questionId2: "/storage/help", + questionId3: "/storage/some-text", + questionId4: "/storage/foo/bar", + realUrl: "/storage/env-123/public/image.jpg", + }; + const result = resolveStorageUrlsInObject(input); + expect(result.questionId1).toBe("/storage/"); + expect(result.questionId2).toBe("/storage/help"); + expect(result.questionId3).toBe("/storage/some-text"); + expect(result.questionId4).toBe("/storage/foo/bar"); + // realUrl still gets resolved because it matches the actual format + expect(result.realUrl).not.toBe("/storage/env-123/public/image.jpg"); + }); + + test("should preserve Date instances", () => { + const date = new Date("2026-01-01"); + expect(resolveStorageUrlsInObject(date)).toBe(date); + }); + + test("should resolve storage URL strings", async () => { + const { resolveStorageUrlsInObject: actual } = + await vi.importActual("@/modules/storage/utils"); + + const result = actual("/storage/env-123/public/image.jpg"); + expect(typeof result).toBe("string"); + expect(result).toContain("/storage/env-123/public/image.jpg"); + expect((result as string).startsWith("http")).toBe(true); + }); + + test("should resolve URLs in arrays", async () => { + const { resolveStorageUrlsInObject: actual } = + await vi.importActual("@/modules/storage/utils"); + + const input = ["/storage/env-123/public/a.jpg", "plain text"]; + const result = actual(input); + + expect(result[0]).toContain("/storage/env-123/public/a.jpg"); + expect(result[0].startsWith("http")).toBe(true); + expect(result[1]).toBe("plain text"); + }); + + test("should resolve URLs in nested objects", async () => { + const { resolveStorageUrlsInObject: actual } = + await vi.importActual("@/modules/storage/utils"); + + const input = { + name: "Test Survey", + welcomeCard: { + fileUrl: "/storage/env-123/public/welcome.png", + headline: "Hello", + }, + elements: [ + { + imageUrl: "/storage/env-123/public/q1.jpg", + choices: [ + { id: "c1", imageUrl: "/storage/env-123/public/choice1.jpg" }, + { id: "c2", imageUrl: "https://external.com/image.jpg" }, + ], + }, + ], + count: 5, + createdAt: new Date("2026-01-01"), + }; + + const result = actual(input); + + expect(result.welcomeCard.fileUrl.startsWith("http")).toBe(true); + expect(result.welcomeCard.headline).toBe("Hello"); + expect(result.elements[0].imageUrl.startsWith("http")).toBe(true); + expect(result.elements[0].choices[0].imageUrl.startsWith("http")).toBe(true); + expect(result.elements[0].choices[1].imageUrl).toBe("https://external.com/image.jpg"); + expect(result.count).toBe(5); + expect(result.createdAt).toEqual(new Date("2026-01-01")); + expect(result.name).toBe("Test Survey"); + }); + + test("should resolve URLs in deeply nested objects", async () => { + const { resolveStorageUrlsInObject: actual } = + await vi.importActual("@/modules/storage/utils"); + + const input = { + level1: { + level2: { + level3: { + level4: { + level5: { + imageUrl: "/storage/env-123/public/deep.png", + items: [ + { + nested: { + url: "/storage/env-123/public/nested.jpg", + label: "keep me", + }, + }, + "plain string", + 42, + null, + ], + }, + }, + sibling: "/storage/env-123/public/sibling.png", + }, + }, + untouched: { a: { b: { c: "no change" } } }, + }, + }; + + const result = actual(input); + + expect(result.level1.level2.level3.level4.level5.imageUrl).toContain( + "/storage/env-123/public/deep.png" + ); + expect(result.level1.level2.level3.level4.level5.imageUrl.startsWith("http")).toBe(true); + + // @ts-expect-error - items is an array of unknown types + expect(result.level1.level2.level3.level4.level5.items[0].nested.url).toContain( + "/storage/env-123/public/nested.jpg" + ); + // @ts-expect-error - items is an array of unknown types + expect(result.level1.level2.level3.level4.level5.items[0].nested.url.startsWith("http")).toBe(true); + // @ts-expect-error - items is an array of unknown types + expect(result.level1.level2.level3.level4.level5.items[0].nested.label).toBe("keep me"); + + expect(result.level1.level2.level3.level4.level5.items[1]).toBe("plain string"); + expect(result.level1.level2.level3.level4.level5.items[2]).toBe(42); + expect(result.level1.level2.level3.level4.level5.items[3]).toBeNull(); + + expect(result.level1.level2.level3.sibling).toContain("/storage/env-123/public/sibling.png"); + expect(result.level1.level2.level3.sibling.startsWith("http")).toBe(true); + + expect(result.level1.untouched.a.b.c).toBe("no change"); + }); + + test("should handle response data with file upload URLs", async () => { + const { resolveStorageUrlsInObject: actual } = + await vi.importActual("@/modules/storage/utils"); + + const responseData = { + questionId1: "text answer", + questionId2: 42, + fileUploadId: ["/storage/env-123/public/doc.pdf", "/storage/env-123/public/img.png"], + }; + + const result = actual(responseData); + + expect(result.questionId1).toBe("text answer"); + expect(result.questionId2).toBe(42); + const fileUrls = result.fileUploadId; + expect(fileUrls[0]).toContain("/storage/env-123/public/doc.pdf"); + expect(fileUrls[0].startsWith("http")).toBe(true); + expect(fileUrls[1]).toContain("/storage/env-123/public/img.png"); + expect(fileUrls[1].startsWith("http")).toBe(true); + }); + }); }); diff --git a/apps/web/modules/storage/utils.ts b/apps/web/modules/storage/utils.ts index 083c0aca693f..777e4c3fe476 100644 --- a/apps/web/modules/storage/utils.ts +++ b/apps/web/modules/storage/utils.ts @@ -151,7 +151,7 @@ export const getErrorResponseFromStorageError = ( /** * Resolves a storage URL to an absolute URL. - * - If already absolute, returns as-is (backward compatibility for old data) + * - If already absolute, returns as-is * - If relative (/storage/...), prepends the appropriate base URL * @param url The storage URL (relative or absolute) * @param accessType The access type to determine which base URL to use (defaults to "public") @@ -163,7 +163,7 @@ export const resolveStorageUrl = ( ): string => { if (!url) return ""; - // Already absolute URL - return as-is (backward compatibility for old data) + // Already absolute URL - return as-is if (url.startsWith("http://") || url.startsWith("https://")) { return url; } @@ -176,3 +176,41 @@ export const resolveStorageUrl = ( return url; }; + +// Matches the actual storage URL format: /storage/{id}/{public|private}/{filename...} +const STORAGE_URL_PATTERN = /^\/storage\/[^/]+\/(public|private)\/.+/; + +const isStorageUrl = (value: string): boolean => STORAGE_URL_PATTERN.test(value); + +export const resolveStorageUrlAuto = (url: string): string => { + if (!isStorageUrl(url)) return url; + const accessType = url.includes("/private/") ? "private" : "public"; + return resolveStorageUrl(url, accessType); +}; + +/** + * Recursively walks an object/array and resolves all relative storage URLs + * Preserves the original structure; skips Date instances and non-object primitives. + */ +export const resolveStorageUrlsInObject = (obj: T): T => { + if (obj === null || obj === undefined) return obj; + + if (typeof obj === "string") { + return resolveStorageUrlAuto(obj) as T; + } + + if (typeof obj !== "object") return obj; + + if (obj instanceof Date) return obj; + + if (Array.isArray(obj)) { + return obj.map((item) => resolveStorageUrlsInObject(item)) as T; + } + + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + result[key] = resolveStorageUrlsInObject(value); + } + + return result as T; +};