diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx index eb7a1b6d21..9d22a6088b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx @@ -384,24 +384,24 @@ export const generateResponseTableColumns = ( const hiddenFieldColumns: ColumnDef[] = survey.hiddenFields.fieldIds ? survey.hiddenFields.fieldIds.map((hiddenFieldId) => { - return { - accessorKey: "HIDDEN_FIELD_" + hiddenFieldId, - header: () => ( -
- - - - {hiddenFieldId} -
- ), - cell: ({ row }) => { - const hiddenFieldResponse = row.original.responseData[hiddenFieldId]; - if (typeof hiddenFieldResponse === "string") { - return
{hiddenFieldResponse}
; - } - }, - }; - }) + return { + accessorKey: "HIDDEN_FIELD_" + hiddenFieldId, + header: () => ( +
+ + + + {hiddenFieldId} +
+ ), + cell: ({ row }) => { + const hiddenFieldResponse = row.original.responseData[hiddenFieldId]; + if (typeof hiddenFieldResponse === "string") { + return
{hiddenFieldResponse}
; + } + }, + }; + }) : []; const metadataColumns = getMetadataColumnsData(t); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx index 93eba1af0b..c631558eb9 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx @@ -8,7 +8,7 @@ import { TSurvey, TSurveyElementSummaryFileUpload } from "@formbricks/types/surv import { TUserLocale } from "@formbricks/types/user"; import { timeSince } from "@/lib/time"; import { getContactIdentifier } from "@/lib/utils/contact"; -import { getOriginalFileNameFromUrl } from "@/modules/storage/utils"; +import { getOriginalFileNameFromUrl } from "@/modules/storage/url-helpers"; import { PersonAvatar } from "@/modules/ui/components/avatars"; import { Button } from "@/modules/ui/components/button"; import { EmptyState } from "@/modules/ui/components/empty-state"; diff --git a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/route.ts b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/route.ts index 749e3fe540..140990e1e8 100644 --- a/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/route.ts +++ b/apps/web/app/storage/[environmentId]/[accessType]/[fileName]/route.ts @@ -8,7 +8,7 @@ import { authorizePrivateDownload } from "@/app/storage/[environmentId]/[accessT import { authOptions } from "@/modules/auth/lib/authOptions"; import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; -import { deleteFile, getSignedUrlForDownload } from "@/modules/storage/service"; +import { deleteFile, getFileStreamForDownload } from "@/modules/storage/service"; import { getErrorResponseFromStorageError } from "@/modules/storage/utils"; import { logFileDeletion } from "./lib/audit-logs"; @@ -39,21 +39,25 @@ export const GET = async ( } } - const signedUrlResult = await getSignedUrlForDownload(fileName, environmentId, accessType); + // Stream the file directly + const streamResult = await getFileStreamForDownload(fileName, environmentId, accessType); - if (!signedUrlResult.ok) { - const errorResponse = getErrorResponseFromStorageError(signedUrlResult.error, { fileName }); + if (!streamResult.ok) { + const errorResponse = getErrorResponseFromStorageError(streamResult.error, { fileName }); return errorResponse; } - return new Response(null, { - status: 302, + const { body, contentType, contentLength } = streamResult.data; + + return new Response(body, { + status: 200, headers: { - Location: signedUrlResult.data, + "Content-Type": contentType, + ...(contentLength > 0 && { "Content-Length": String(contentLength) }), "Cache-Control": accessType === "private" ? "no-store, no-cache, must-revalidate" - : "public, max-age=300, s-maxage=300, stale-while-revalidate=300", + : "public, max-age=31536000, immutable", }, }); }; diff --git a/apps/web/modules/api/lib/validation.ts b/apps/web/modules/api/lib/validation.ts index 03f0fec757..67861c6bbd 100644 --- a/apps/web/modules/api/lib/validation.ts +++ b/apps/web/modules/api/lib/validation.ts @@ -44,7 +44,9 @@ export const validateResponseData = ( // If response is not finished, only validate elements that are present in the response data // This prevents "required" errors for fields the user hasn't reached yet - const elementsToValidate = finished ? allElements : allElements.filter((element) => Object.keys(responseData).includes(element.id)); + const elementsToValidate = finished + ? allElements + : allElements.filter((element) => Object.keys(responseData).includes(element.id)); // Validate selected elements const errorMap = validateBlockResponses(elementsToValidate, responseData, languageCode); diff --git a/apps/web/modules/ee/whitelabel/favicon-customization/actions.ts b/apps/web/modules/ee/whitelabel/favicon-customization/actions.ts index 0595270d4f..0e947553a9 100644 --- a/apps/web/modules/ee/whitelabel/favicon-customization/actions.ts +++ b/apps/web/modules/ee/whitelabel/favicon-customization/actions.ts @@ -1,7 +1,7 @@ "use server"; import { z } from "zod"; -import { ZId, ZUrl } from "@formbricks/types/common"; +import { ZId, ZStorageUrl } from "@formbricks/types/common"; import { authenticatedActionClient } from "@/lib/utils/action-client"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; @@ -11,7 +11,7 @@ import { updateOrganizationFaviconUrl } from "@/modules/ee/whitelabel/favicon-cu const ZUpdateOrganizationFaviconUrlAction = z.object({ organizationId: ZId, - faviconUrl: ZUrl, + faviconUrl: ZStorageUrl, }); export const updateOrganizationFaviconUrlAction = authenticatedActionClient diff --git a/apps/web/modules/ee/whitelabel/favicon-customization/lib/organization.ts b/apps/web/modules/ee/whitelabel/favicon-customization/lib/organization.ts index 77d5c8e519..4384873f87 100644 --- a/apps/web/modules/ee/whitelabel/favicon-customization/lib/organization.ts +++ b/apps/web/modules/ee/whitelabel/favicon-customization/lib/organization.ts @@ -2,7 +2,7 @@ import "server-only"; import { Prisma } from "@prisma/client"; import { prisma } from "@formbricks/database"; import { PrismaErrorType } from "@formbricks/database/types/error"; -import { ZId, ZUrl } from "@formbricks/types/common"; +import { ZId, ZStorageUrl } from "@formbricks/types/common"; import { ResourceNotFoundError } from "@formbricks/types/errors"; import { TOrganizationWhitelabel } from "@formbricks/types/organizations"; import { validateInputs } from "@/lib/utils/validate"; @@ -11,7 +11,7 @@ export const updateOrganizationFaviconUrl = async ( organizationId: string, faviconUrl: string | null ): Promise => { - validateInputs([organizationId, ZId], [faviconUrl, ZUrl.nullable()]); + validateInputs([organizationId, ZId], [faviconUrl, ZStorageUrl.nullable()]); try { const organization = await prisma.organization.findUnique({ diff --git a/apps/web/modules/email/components/preview-email-template.tsx b/apps/web/modules/email/components/preview-email-template.tsx index 9a435fe4d8..b3483fbb39 100644 --- a/apps/web/modules/email/components/preview-email-template.tsx +++ b/apps/web/modules/email/components/preview-email-template.tsx @@ -24,6 +24,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils"; import { isLight, mixColor } from "@/lib/utils/colors"; import { parseRecallInfo } from "@/lib/utils/recall"; import { RatingSmiley } from "@/modules/analysis/components/RatingSmiley"; +import { resolveStorageUrl } from "@/modules/storage/utils"; import { getNPSOptionColor, getRatingNumberOptionColor } from "../lib/utils"; interface PreviewEmailTemplateProps { @@ -308,7 +309,7 @@ export async function PreviewEmailTemplate({ ) : ( - + ) )} diff --git a/apps/web/modules/email/index.tsx b/apps/web/modules/email/index.tsx index 93111b4775..d90be82b12 100644 --- a/apps/web/modules/email/index.tsx +++ b/apps/web/modules/email/index.tsx @@ -17,6 +17,7 @@ import { logger } from "@formbricks/logger"; import type { TLinkSurveyEmailData } from "@formbricks/types/email"; import { InvalidInputError } from "@formbricks/types/errors"; import type { TResponse } from "@formbricks/types/responses"; +import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import type { TSurvey } from "@formbricks/types/surveys/types"; import { TUserEmail, TUserLocale } from "@formbricks/types/user"; import { @@ -41,6 +42,7 @@ import { createEmailChangeToken, createInviteToken, createToken, createTokenForL import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getElementResponseMapping } from "@/lib/responses"; import { getTranslate } from "@/lingodotdev/server"; +import { resolveStorageUrl } from "@/modules/storage/utils"; export const IS_SMTP_CONFIGURED = Boolean(SMTP_HOST && SMTP_PORT); @@ -241,6 +243,22 @@ export const sendResponseFinishedEmail = async ( // Pre-process the element response mapping before passing to email const elements = getElementResponseMapping(survey, response); + // Resolve relative storage URLs to absolute URLs for email rendering + const elementsWithResolvedUrls = elements.map((element) => { + if ( + (element.type === TSurveyElementTypeEnum.PictureSelection || + element.type === TSurveyElementTypeEnum.FileUpload) && + Array.isArray(element.response) + ) { + return { + ...element, + response: element.response.map((url) => resolveStorageUrl(url)), + }; + } + + return element; + }); + const html = await renderResponseFinishedEmail({ survey, responseCount, @@ -248,7 +266,7 @@ export const sendResponseFinishedEmail = async ( WEBAPP_URL, environmentId, organization, - elements, + elements: elementsWithResolvedUrls, t, ...legalProps, }); @@ -276,10 +294,12 @@ export const sendEmbedSurveyPreviewEmail = async ( logoUrl?: string ): Promise => { const t = await getTranslate(locale); + // Resolve relative storage URLs to absolute URLs for email rendering + const resolvedLogoUrl = logoUrl ? resolveStorageUrl(logoUrl) : undefined; const html = await renderEmbedSurveyPreviewEmail({ html: innerHtml, environmentId, - logoUrl, + logoUrl: resolvedLogoUrl, t, ...legalProps, }); @@ -297,9 +317,11 @@ export const sendEmailCustomizationPreviewEmail = async ( logoUrl?: string ): Promise => { const t = await getTranslate(locale); + // Resolve relative storage URLs to absolute URLs for email rendering + const resolvedLogoUrl = logoUrl ? resolveStorageUrl(logoUrl) : undefined; const emailHtmlBody = await renderEmailCustomizationPreviewEmail({ userName, - logoUrl, + logoUrl: resolvedLogoUrl, t, ...legalProps, }); @@ -316,7 +338,8 @@ export const sendLinkSurveyToVerifiedEmail = async (data: TLinkSurveyEmailData): const email = data.email; const surveyName = data.surveyName; const singleUseId = data.suId; - const logoUrl = data.logoUrl || ""; + // Resolve relative storage URLs to absolute URLs for email rendering + const logoUrl = data.logoUrl ? resolveStorageUrl(data.logoUrl) : ""; const token = createTokenForLinkSurvey(surveyId, email); const t = await getTranslate(data.locale); const getSurveyLink = (): string => { diff --git a/apps/web/modules/storage/file-upload.test.ts b/apps/web/modules/storage/file-upload.test.ts index 7da56db32c..13823ed37e 100644 --- a/apps/web/modules/storage/file-upload.test.ts +++ b/apps/web/modules/storage/file-upload.test.ts @@ -72,13 +72,13 @@ describe("fileUpload", () => { test("should handle successful file upload with presigned fields", async () => { const file = createMockFile("test.jpg", "image/jpeg", 1000); - // Mock successful API response + // Mock successful API response - now returns relative path mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { signedUrl: "https://s3.example.com/upload", - fileUrl: "https://s3.example.com/file.jpg", + fileUrl: "/storage/test-env/public/file.jpg", presignedFields: { key: "value", }, @@ -98,18 +98,18 @@ describe("fileUpload", () => { const result = await fileUploadModule.handleFileUpload(file, "test-env"); expect(result.error).toBeUndefined(); - expect(result.url).toBe("https://s3.example.com/file.jpg"); + expect(result.url).toBe("/storage/test-env/public/file.jpg"); }); test("should handle upload error with presigned fields", async () => { const file = createMockFile("test.jpg", "image/jpeg", 1000); - // Mock successful API response + // Mock successful API response - now returns relative path mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { signedUrl: "https://s3.example.com/upload", - fileUrl: "https://s3.example.com/file.jpg", + fileUrl: "/storage/test-env/public/file.jpg", presignedFields: { key: "value", }, @@ -134,13 +134,13 @@ describe("fileUpload", () => { test("should handle upload error", async () => { const file = createMockFile("test.jpg", "image/jpeg", 1000); - // Mock successful API response + // Mock successful API response - now returns relative path mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ data: { signedUrl: "https://s3.example.com/upload", - fileUrl: "https://s3.example.com/file.jpg", + fileUrl: "/storage/test-env/public/file.jpg", presignedFields: { key: "value", }, diff --git a/apps/web/modules/storage/service.test.ts b/apps/web/modules/storage/service.test.ts index 9d77f3d10c..280a6545e5 100644 --- a/apps/web/modules/storage/service.test.ts +++ b/apps/web/modules/storage/service.test.ts @@ -5,7 +5,7 @@ import { TAccessType } from "@formbricks/types/storage"; import { deleteFile, deleteFilesByEnvironmentId, - getSignedUrlForDownload, + getFileStreamForDownload, getSignedUrlForUpload, } from "./service"; @@ -14,14 +14,6 @@ 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(), @@ -38,22 +30,22 @@ vi.mock("@formbricks/storage", () => ({ }, deleteFile: vi.fn(), deleteFilesByPrefix: vi.fn(), + getFileStream: vi.fn(), getSignedDownloadUrl: vi.fn(), getSignedUploadUrl: vi.fn(), })); // Import mocked dependencies const { logger } = await import("@formbricks/logger"); -const { getPublicDomain } = await import("@/lib/getPublicUrl"); +const storageModule = await import("@formbricks/storage"); const { deleteFile: deleteFileFromS3, deleteFilesByPrefix, - getSignedDownloadUrl, getSignedUploadUrl, -} = await import("@formbricks/storage"); - + getFileStream, +} = storageModule; type MockedSignedUploadReturn = Awaited>; -type MockedSignedDownloadReturn = Awaited>; +type MockedFileStreamReturn = Awaited>; type MockedDeleteFileReturn = Awaited>; type MockedDeleteFilesByPrefixReturn = Awaited>; @@ -63,7 +55,6 @@ describe("storage service", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(randomUUID).mockReturnValue(mockUUID); - vi.mocked(getPublicDomain).mockReturnValue("https://public.example.com"); }); describe("getSignedUrlForUpload", () => { @@ -90,7 +81,7 @@ describe("storage service", () => { 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`, + fileUrl: `/storage/env-123/public/test-image--fid--${mockUUID}.jpg`, }); } @@ -102,7 +93,7 @@ describe("storage service", () => { ); }); - test("should use WEBAPP_URL for private files", async () => { + test("should return relative URL for private files", async () => { const mockSignedUrlResponse = { ok: true, data: { @@ -122,9 +113,7 @@ describe("storage service", () => { 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` - ); + expect(result.data.fileUrl).toBe(`/storage/env-123/private/test-doc--fid--${mockUUID}.pdf`); } }); @@ -149,9 +138,7 @@ describe("storage service", () => { 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(result.data.fileUrl).toBe(`/storage/env-123/public/testfile--fid--${mockUUID}.txt`); } expect(getSignedUploadUrl).toHaveBeenCalledWith( @@ -276,86 +263,6 @@ describe("storage service", () => { }); }); - 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 = { @@ -433,4 +340,147 @@ describe("storage service", () => { expect(deleteFilesByPrefix).toHaveBeenCalledWith("env-123"); }); }); + + describe("getFileStreamForDownload", () => { + test("should return file stream for public file", async () => { + const mockStream = new ReadableStream(); + const mockStreamResult = { + ok: true, + data: { + body: mockStream, + contentType: "image/jpeg", + contentLength: 12345, + }, + } as MockedFileStreamReturn; + + vi.mocked(getFileStream).mockResolvedValue(mockStreamResult); + + const result = await getFileStreamForDownload("test-image.jpg", "env-123", "public" as TAccessType); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.body).toBe(mockStream); + expect(result.data.contentType).toBe("image/jpeg"); + expect(result.data.contentLength).toBe(12345); + } + expect(getFileStream).toHaveBeenCalledWith("env-123/public/test-image.jpg"); + }); + + test("should return file stream for private file", async () => { + const mockStream = new ReadableStream(); + const mockStreamResult = { + ok: true, + data: { + body: mockStream, + contentType: "application/pdf", + contentLength: 54321, + }, + } as MockedFileStreamReturn; + + vi.mocked(getFileStream).mockResolvedValue(mockStreamResult); + + const result = await getFileStreamForDownload("document.pdf", "env-456", "private" as TAccessType); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.contentType).toBe("application/pdf"); + } + expect(getFileStream).toHaveBeenCalledWith("env-456/private/document.pdf"); + }); + + test("should decode URL-encoded filename", async () => { + const mockStream = new ReadableStream(); + const mockStreamResult = { + ok: true, + data: { + body: mockStream, + contentType: "image/png", + contentLength: 1000, + }, + } as MockedFileStreamReturn; + + vi.mocked(getFileStream).mockResolvedValue(mockStreamResult); + + // URL-encoded filename with spaces: "my file.png" -> "my%20file.png" + const result = await getFileStreamForDownload("my%20file.png", "env-123", "public" as TAccessType); + + expect(result.ok).toBe(true); + // Should decode %20 to space before passing to getFileStream + expect(getFileStream).toHaveBeenCalledWith("env-123/public/my file.png"); + }); + + test("should return error when getFileStream fails with FileNotFoundError", async () => { + const mockErrorResult = { + ok: false, + error: { + code: StorageErrorCode.FileNotFoundError, + }, + } as MockedFileStreamReturn; + + vi.mocked(getFileStream).mockResolvedValue(mockErrorResult); + + const result = await getFileStreamForDownload("missing-file.jpg", "env-123", "public" as TAccessType); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(StorageErrorCode.FileNotFoundError); + } + }); + + test("should return error when getFileStream fails with S3ClientError", async () => { + const mockErrorResult = { + ok: false, + error: { + code: StorageErrorCode.S3ClientError, + }, + } as MockedFileStreamReturn; + + vi.mocked(getFileStream).mockResolvedValue(mockErrorResult); + + const result = await getFileStreamForDownload("some-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(getFileStream).mockRejectedValue(new Error("Unexpected S3 error")); + + const result = await getFileStreamForDownload("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 file stream for download" + ); + }); + + test("should handle filename with fid pattern", async () => { + const mockStream = new ReadableStream(); + const mockStreamResult = { + ok: true, + data: { + body: mockStream, + contentType: "image/jpeg", + contentLength: 5000, + }, + } as MockedFileStreamReturn; + + vi.mocked(getFileStream).mockResolvedValue(mockStreamResult); + + const result = await getFileStreamForDownload( + "photo--fid--abc123-def456.jpg", + "env-123", + "public" as TAccessType + ); + + expect(result.ok).toBe(true); + expect(getFileStream).toHaveBeenCalledWith("env-123/public/photo--fid--abc123-def456.jpg"); + }); + }); }); diff --git a/apps/web/modules/storage/service.ts b/apps/web/modules/storage/service.ts index bcfaf2d311..2a7bd990c3 100644 --- a/apps/web/modules/storage/service.ts +++ b/apps/web/modules/storage/service.ts @@ -1,17 +1,16 @@ import { randomUUID } from "crypto"; import { logger } from "@formbricks/logger"; import { + type FileStreamResult, type StorageError, StorageErrorCode, deleteFile as deleteFileFromS3, deleteFilesByPrefix, - getSignedDownloadUrl, + getFileStream, getSignedUploadUrl, } from "@formbricks/storage"; import { Result, err, ok } from "@formbricks/types/error-handlers"; import { TAccessType } from "@formbricks/types/storage"; -import { WEBAPP_URL } from "@/lib/constants"; -import { getPublicDomain } from "@/lib/getPublicUrl"; import { sanitizeFileName } from "./utils"; export const getSignedUrlForUpload = async ( @@ -51,15 +50,11 @@ export const getSignedUrlForUpload = async ( return signedUrlResult; } - // Use PUBLIC_URL for public files, WEBAPP_URL for private files - const baseUrl = accessType === "public" ? getPublicDomain() : WEBAPP_URL; - + // Return relative path - can be resolved to absolute URL at runtime when needed return ok({ signedUrl: signedUrlResult.data.signedUrl, presignedFields: signedUrlResult.data.presignedFields, - fileUrl: new URL( - `${baseUrl}/storage/${environmentId}/${accessType}/${encodeURIComponent(updatedFileName)}` - ).href, + fileUrl: `/storage/${environmentId}/${accessType}/${encodeURIComponent(updatedFileName)}`, }); } catch (error) { logger.error({ error }, "Error getting signed url for upload"); @@ -70,24 +65,28 @@ export const getSignedUrlForUpload = async ( } }; -export const getSignedUrlForDownload = async ( +/** + * Get a file stream for downloading/streaming files directly + * Use this instead of signed URL redirect for Next.js Image component compatibility + */ +export const getFileStreamForDownload = async ( fileName: string, environmentId: string, accessType: TAccessType -): Promise> => { +): Promise> => { try { const fileNameDecoded = decodeURIComponent(fileName); const fileKey = `${environmentId}/${accessType}/${fileNameDecoded}`; - const signedUrlResult = await getSignedDownloadUrl(fileKey); + const streamResult = await getFileStream(fileKey); - if (!signedUrlResult.ok) { - return signedUrlResult; + if (!streamResult.ok) { + return streamResult; } - return signedUrlResult; + return streamResult; } catch (error) { - logger.error({ error }, "Error getting signed url for download"); + logger.error({ error }, "Error getting file stream for download"); return err({ code: StorageErrorCode.Unknown, diff --git a/apps/web/modules/storage/url-helpers.ts b/apps/web/modules/storage/url-helpers.ts new file mode 100644 index 0000000000..0537bb5aa3 --- /dev/null +++ b/apps/web/modules/storage/url-helpers.ts @@ -0,0 +1,30 @@ +/** + * Client-safe URL helper utilities for storage files. + * These functions can be used in both server and client components. + */ + +/** + * Extracts the original file name from a storage URL. + * Handles both relative paths (/storage/...) and absolute URLs. + * @param fileURL The storage URL to parse + * @returns The original file name, or empty string if parsing fails + */ +export const getOriginalFileNameFromUrl = (fileURL: string): string => { + try { + const lastSegment = fileURL.startsWith("/storage/") + ? (fileURL.split("/").pop() ?? "") + : (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 { + return ""; + } +}; diff --git a/apps/web/modules/storage/utils.test.ts b/apps/web/modules/storage/utils.test.ts index e8c96869aa..bbaeb26fc1 100644 --- a/apps/web/modules/storage/utils.test.ts +++ b/apps/web/modules/storage/utils.test.ts @@ -7,6 +7,7 @@ import { isAllowedFileExtension, isValidFileTypeForExtension, isValidImageFile, + resolveStorageUrl, sanitizeFileName, validateFileUploads, validateSingleFile, @@ -145,7 +146,8 @@ describe("storage utils", () => { const { getOriginalFileNameFromUrl } = await vi.importActual("@/modules/storage/utils"); const path = "/storage/env/public/Document%20Name.pdf"; - expect(getOriginalFileNameFromUrl(path)).toBe("/storage/env/public/Document Name.pdf"); + // Function extracts filename, not full path + expect(getOriginalFileNameFromUrl(path)).toBe("Document Name.pdf"); }); test("returns empty string on invalid URL input", async () => { @@ -396,4 +398,38 @@ describe("storage utils", () => { expect(isValidImageFile("https://example.com/image.JPG")).toBe(true); }); }); + + describe("resolveStorageUrl", () => { + test("should return empty string for null or undefined input", () => { + expect(resolveStorageUrl(null)).toBe(""); + expect(resolveStorageUrl(undefined)).toBe(""); + expect(resolveStorageUrl("")).toBe(""); + }); + + test("should return absolute URL unchanged (backward compatibility)", () => { + const httpsUrl = "https://example.com/storage/env-123/public/image.jpg"; + const httpUrl = "http://example.com/storage/env-123/public/image.jpg"; + + expect(resolveStorageUrl(httpsUrl)).toBe(httpsUrl); + expect(resolveStorageUrl(httpUrl)).toBe(httpUrl); + }); + + 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); + }); + + test("should return non-storage paths unchanged", () => { + expect(resolveStorageUrl("/some/other/path")).toBe("/some/other/path"); + expect(resolveStorageUrl("relative/path.jpg")).toBe("relative/path.jpg"); + }); + }); }); diff --git a/apps/web/modules/storage/utils.ts b/apps/web/modules/storage/utils.ts index 3fd1611db0..083c0aca69 100644 --- a/apps/web/modules/storage/utils.ts +++ b/apps/web/modules/storage/utils.ts @@ -1,30 +1,15 @@ -import { logger } from "@formbricks/logger"; +import "server-only"; 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"; import { responses } from "@/app/lib/api/response"; +import { WEBAPP_URL } from "@/lib/constants"; +import { getPublicDomain } from "@/lib/getPublicUrl"; +import { getOriginalFileNameFromUrl } from "./url-helpers"; -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 ""; - } -}; +// Re-export for backward compatibility with server-side code +export { getOriginalFileNameFromUrl } from "./url-helpers"; /** * Sanitize a provided file name to a safe subset. @@ -163,3 +148,31 @@ export const getErrorResponseFromStorageError = ( } } }; + +/** + * Resolves a storage URL to an absolute URL. + * - If already absolute, returns as-is (backward compatibility for old data) + * - 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") + * @returns The resolved absolute URL, or empty string if url is falsy + */ +export const resolveStorageUrl = ( + url: string | undefined | null, + accessType: "public" | "private" = "public" +): string => { + if (!url) return ""; + + // Already absolute URL - return as-is (backward compatibility for old data) + if (url.startsWith("http://") || url.startsWith("https://")) { + return url; + } + + // Relative path - resolve with base URL + if (url.startsWith("/storage/")) { + const baseUrl = accessType === "public" ? getPublicDomain() : WEBAPP_URL; + return `${baseUrl}${url}`; + } + + return url; +}; diff --git a/apps/web/modules/survey/editor/components/multiple-choice-element-form.tsx b/apps/web/modules/survey/editor/components/multiple-choice-element-form.tsx index 2ea9a74f20..ccb7c389da 100644 --- a/apps/web/modules/survey/editor/components/multiple-choice-element-form.tsx +++ b/apps/web/modules/survey/editor/components/multiple-choice-element-form.tsx @@ -10,7 +10,11 @@ import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils"; import { TI18nString } from "@formbricks/types/i18n"; -import { TMultipleChoiceOptionDisplayType, TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements"; +import { + TMultipleChoiceOptionDisplayType, + TSurveyElementTypeEnum, + TSurveyMultipleChoiceElement, +} from "@formbricks/types/surveys/elements"; import { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; diff --git a/apps/web/modules/survey/editor/components/survey-placement-card.tsx b/apps/web/modules/survey/editor/components/survey-placement-card.tsx index 1eb3dcf1e8..8e83e76162 100644 --- a/apps/web/modules/survey/editor/components/survey-placement-card.tsx +++ b/apps/web/modules/survey/editor/components/survey-placement-card.tsx @@ -89,7 +89,7 @@ export const SurveyPlacementCard = ({ asChild className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
-
+
({ - element: e.element, - response: e.response, - type: e.type, - })) + ? getElementResponseMapping(survey, response).map((e) => { + // Resolve URLs for picture selection and file upload responses + if ( + (e.type === TSurveyElementTypeEnum.PictureSelection || + e.type === TSurveyElementTypeEnum.FileUpload) && + Array.isArray(e.response) + ) { + return { + element: e.element, + response: e.response.map((url) => resolveStorageUrl(url)), + type: e.type, + }; + } + return { + element: e.element, + response: e.response, + type: e.type, + }; + }) : []; // Process variables diff --git a/apps/web/modules/ui/components/file-upload-response/index.tsx b/apps/web/modules/ui/components/file-upload-response/index.tsx index 1ff52dd3d1..e5e53525aa 100644 --- a/apps/web/modules/ui/components/file-upload-response/index.tsx +++ b/apps/web/modules/ui/components/file-upload-response/index.tsx @@ -2,7 +2,7 @@ import { DownloadIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { getOriginalFileNameFromUrl } from "@/modules/storage/utils"; +import { getOriginalFileNameFromUrl } from "@/modules/storage/url-helpers"; interface FileUploadResponseProps { selected: string[]; diff --git a/apps/web/modules/ui/components/theme-styling-preview-survey/index.tsx b/apps/web/modules/ui/components/theme-styling-preview-survey/index.tsx index e449a6988a..b4da37fb37 100644 --- a/apps/web/modules/ui/components/theme-styling-preview-survey/index.tsx +++ b/apps/web/modules/ui/components/theme-styling-preview-survey/index.tsx @@ -187,7 +187,7 @@ export const ThemeStylingPreviewSurvey = ({ ContentRef={ContentRef as React.MutableRefObject | null} isEditorView> {!project.styling?.isLogoHidden && ( - )} diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index dfa2b290ef..f08da6d389 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -9,11 +9,6 @@ jiti("./lib/env"); /** @type {import('next').NextConfig} */ -const getHostname = (url) => { - const urlObj = new URL(url); - return urlObj.hostname; -}; - const nextConfig = { assetPrefix: process.env.ASSET_PREFIX_URL || undefined, basePath: process.env.BASE_PATH || undefined, diff --git a/apps/web/playwright/survey.spec.ts b/apps/web/playwright/survey.spec.ts index 47f664c577..68e553c307 100644 --- a/apps/web/playwright/survey.spec.ts +++ b/apps/web/playwright/survey.spec.ts @@ -446,9 +446,9 @@ test.describe("Multi Language Survey Create", async () => { .getByRole("textbox", { name: "Button Label", exact: true }) .first() .fill(surveys.germanCreate.next); - await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click(); + await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click(); await page - .getByRole("textbox", { name: '“Back” Button Label', exact: true }) + .getByRole("textbox", { name: "“Back” Button Label", exact: true }) .first() .fill(surveys.germanCreate.back); await page @@ -473,9 +473,9 @@ test.describe("Multi Language Survey Create", async () => { .getByRole("textbox", { name: "Button Label", exact: true }) .first() .fill(surveys.germanCreate.next); - await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click(); + await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click(); await page - .getByRole("textbox", { name: '“Back” Button Label', exact: true }) + .getByRole("textbox", { name: "“Back” Button Label", exact: true }) .first() .fill(surveys.germanCreate.back); await page @@ -493,9 +493,9 @@ test.describe("Multi Language Survey Create", async () => { .getByRole("textbox", { name: "Button Label", exact: true }) .first() .fill(surveys.germanCreate.next); - await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click(); + await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click(); await page - .getByRole("textbox", { name: '“Back” Button Label', exact: true }) + .getByRole("textbox", { name: "“Back” Button Label", exact: true }) .first() .fill(surveys.germanCreate.back); await page @@ -517,9 +517,9 @@ test.describe("Multi Language Survey Create", async () => { .getByRole("textbox", { name: "Button Label", exact: true }) .first() .fill(surveys.germanCreate.next); - await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click(); + await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click(); await page - .getByRole("textbox", { name: '“Back” Button Label', exact: true }) + .getByRole("textbox", { name: "“Back” Button Label", exact: true }) .first() .fill(surveys.germanCreate.back); await page @@ -541,9 +541,9 @@ test.describe("Multi Language Survey Create", async () => { .getByRole("textbox", { name: "Button Label", exact: true }) .first() .fill(surveys.germanCreate.next); - await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click(); + await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click(); await page - .getByRole("textbox", { name: '“Back” Button Label', exact: true }) + .getByRole("textbox", { name: "“Back” Button Label", exact: true }) .first() .fill(surveys.germanCreate.back); await page @@ -561,9 +561,9 @@ test.describe("Multi Language Survey Create", async () => { .getByRole("textbox", { name: "Button Label", exact: true }) .first() .fill(surveys.germanCreate.next); - await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click(); + await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click(); await page - .getByRole("textbox", { name: '“Back” Button Label', exact: true }) + .getByRole("textbox", { name: "“Back” Button Label", exact: true }) .first() .fill(surveys.germanCreate.back); await page @@ -581,9 +581,9 @@ test.describe("Multi Language Survey Create", async () => { .getByRole("textbox", { name: "Button Label", exact: true }) .first() .fill(surveys.germanCreate.next); - await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click(); + await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click(); await page - .getByRole("textbox", { name: '“Back” Button Label', exact: true }) + .getByRole("textbox", { name: "“Back” Button Label", exact: true }) .first() .fill(surveys.germanCreate.back); await page @@ -615,9 +615,9 @@ test.describe("Multi Language Survey Create", async () => { .getByRole("textbox", { name: "Button Label", exact: true }) .first() .fill(surveys.germanCreate.next); - await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click(); + await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click(); await page - .getByRole("textbox", { name: '“Back” Button Label', exact: true }) + .getByRole("textbox", { name: "“Back” Button Label", exact: true }) .first() .fill(surveys.germanCreate.back); await page @@ -657,9 +657,9 @@ test.describe("Multi Language Survey Create", async () => { .getByRole("textbox", { name: "Button Label", exact: true }) .first() .fill(surveys.germanCreate.next); - await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click(); + await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click(); await page - .getByRole("textbox", { name: '“Back” Button Label', exact: true }) + .getByRole("textbox", { name: "“Back” Button Label", exact: true }) .first() .fill(surveys.germanCreate.back); await page @@ -687,9 +687,9 @@ test.describe("Multi Language Survey Create", async () => { .getByRole("textbox", { name: "Button Label", exact: true }) .first() .fill(surveys.germanCreate.next); - await page.getByRole("textbox", { name: '“Back” Button Label', exact: true }).first().click(); + await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click(); await page - .getByRole("textbox", { name: '“Back” Button Label', exact: true }) + .getByRole("textbox", { name: "“Back” Button Label", exact: true }) .first() .fill(surveys.germanCreate.back); await page diff --git a/apps/web/playwright/utils/helper.ts b/apps/web/playwright/utils/helper.ts index 271bda2418..eddb85b8de 100644 --- a/apps/web/playwright/utils/helper.ts +++ b/apps/web/playwright/utils/helper.ts @@ -220,7 +220,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => { await fillRichTextEditor(page, "Description", params.singleSelectQuestion.description); await page.getByPlaceholder("Option 1").fill(params.singleSelectQuestion.options[0]); await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]); - await page.getByRole("button", { name: 'Add “Other”', exact: true }).click(); + await page.getByRole("button", { name: "Add “Other”", exact: true }).click(); // Multi Select Question await page @@ -440,7 +440,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith await fillRichTextEditor(page, "Description", params.singleSelectQuestion.description); await page.getByPlaceholder("Option 1").fill(params.singleSelectQuestion.options[0]); await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]); - await page.getByRole("button", { name: 'Add “Other”', exact: true }).click(); + await page.getByRole("button", { name: "Add “Other”", exact: true }).click(); // Multi Select Question await page diff --git a/packages/database/migration/20260204174943_migrate_storage_urls_to_relative/migration.ts b/packages/database/migration/20260204174943_migrate_storage_urls_to_relative/migration.ts new file mode 100644 index 0000000000..a5b7a5e10a --- /dev/null +++ b/packages/database/migration/20260204174943_migrate_storage_urls_to_relative/migration.ts @@ -0,0 +1,356 @@ +/** + * Data Migration: Convert absolute storage URLs to relative paths + * + * This migration converts URLs like: + * http://localhost:3000/storage/env123/public/image.png + * https://app.formbricks.com/storage/env123/public/image.png + * + * To relative paths: + * /storage/env123/public/image.png + * + * This is needed because: + * 1. Next.js 16+ blocks image optimization for private IPs + * 2. Relative paths work with the new streaming endpoint + * 3. Self-hosted users can change their domain without breaking images + * + * Tables affected: + * - Survey: welcomeCard, questions, blocks, endings, styling, metadata + * - Project: styling, logo + * - Organization: whitelabel + * - Response: data (file upload responses) + */ +import { Prisma } from "@prisma/client"; +import { logger } from "@formbricks/logger"; +import type { MigrationScript } from "../../src/scripts/migration-runner"; +import type { + MigrationStats, + OrganizationRecord, + ProjectRecord, + ResponseRecord, + SurveyRecord, +} from "./types"; +import { + containsAbsoluteStorageUrl, + getUrlConversionCount, + resetUrlConversionCount, + transformJsonUrls, +} from "./utils"; + +const BATCH_SIZE = 500; + +export const migrateStorageUrlsToRelative: MigrationScript = { + type: "data", + id: "cm6xq8k2n0001l508storage01", + name: "20260204174943_migrate_storage_urls_to_relative", + run: async ({ tx }) => { + const stats: MigrationStats = { + surveysProcessed: 0, + surveysUpdated: 0, + projectsProcessed: 0, + projectsUpdated: 0, + organizationsProcessed: 0, + organizationsUpdated: 0, + responsesProcessed: 0, + responsesUpdated: 0, + urlsConverted: 0, + errors: 0, + }; + + resetUrlConversionCount(); + + // ==================== MIGRATE SURVEYS ==================== + logger.info("Starting Survey migration..."); + + // Use '%http%/storage/%' to match absolute URLs anywhere in the JSON text + // This won't match already-migrated relative paths like /storage/... (no 'http' before it) + const surveyQuery = Prisma.sql` + SELECT id, "welcomeCard", questions, blocks, endings, styling, metadata + FROM "Survey" + WHERE "welcomeCard"::text LIKE '%http%/storage/%' + OR questions::text LIKE '%http%/storage/%' + OR blocks::text LIKE '%http%/storage/%' + OR endings::text LIKE '%http%/storage/%' + OR styling::text LIKE '%http%/storage/%' + OR metadata::text LIKE '%http%/storage/%' + `; + + const surveysToMigrate: SurveyRecord[] = await tx.$queryRaw(surveyQuery); + logger.info(`Found ${surveysToMigrate.length} surveys with storage URLs`); + + const surveyUpdates: { id: string; data: Partial }[] = []; + + for (const survey of surveysToMigrate) { + stats.surveysProcessed++; + + const updates: Partial = {}; + let hasChanges = false; + + // Transform each JSON column if it contains absolute storage URLs + if (containsAbsoluteStorageUrl(survey.welcomeCard)) { + updates.welcomeCard = transformJsonUrls(JSON.parse(JSON.stringify(survey.welcomeCard))); + hasChanges = true; + } + + if (containsAbsoluteStorageUrl(survey.questions)) { + updates.questions = transformJsonUrls(JSON.parse(JSON.stringify(survey.questions))); + hasChanges = true; + } + + if (containsAbsoluteStorageUrl(survey.blocks)) { + updates.blocks = transformJsonUrls(JSON.parse(JSON.stringify(survey.blocks))) as unknown[]; + hasChanges = true; + } + + if (containsAbsoluteStorageUrl(survey.endings)) { + updates.endings = transformJsonUrls(JSON.parse(JSON.stringify(survey.endings))) as unknown[]; + hasChanges = true; + } + + if (containsAbsoluteStorageUrl(survey.styling)) { + updates.styling = transformJsonUrls(JSON.parse(JSON.stringify(survey.styling))); + hasChanges = true; + } + + if (containsAbsoluteStorageUrl(survey.metadata)) { + updates.metadata = transformJsonUrls(JSON.parse(JSON.stringify(survey.metadata))); + hasChanges = true; + } + + if (hasChanges) { + surveyUpdates.push({ id: survey.id, data: updates }); + stats.surveysUpdated++; + } + } + + // Batch update surveys + for (let i = 0; i < surveyUpdates.length; i += BATCH_SIZE) { + const batch = surveyUpdates.slice(i, i + BATCH_SIZE); + + for (const update of batch) { + const setClauses: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + if (update.data.welcomeCard !== undefined) { + setClauses.push(`"welcomeCard" = $${paramIndex}::jsonb`); + values.push(JSON.stringify(update.data.welcomeCard)); + paramIndex++; + } + if (update.data.questions !== undefined) { + setClauses.push(`questions = $${paramIndex}::jsonb`); + values.push(JSON.stringify(update.data.questions)); + paramIndex++; + } + if (update.data.blocks !== undefined) { + setClauses.push( + `blocks = (SELECT array_agg(elem) FROM jsonb_array_elements($${paramIndex}::jsonb) AS elem)` + ); + values.push(JSON.stringify(update.data.blocks)); + paramIndex++; + } + if (update.data.endings !== undefined) { + setClauses.push( + `endings = (SELECT array_agg(elem) FROM jsonb_array_elements($${paramIndex}::jsonb) AS elem)` + ); + values.push(JSON.stringify(update.data.endings)); + paramIndex++; + } + if (update.data.styling !== undefined) { + setClauses.push(`styling = $${paramIndex}::jsonb`); + values.push(JSON.stringify(update.data.styling)); + paramIndex++; + } + if (update.data.metadata !== undefined) { + setClauses.push(`metadata = $${paramIndex}::jsonb`); + values.push(JSON.stringify(update.data.metadata)); + paramIndex++; + } + + values.push(update.id); + + if (setClauses.length > 0) { + await tx.$executeRawUnsafe( + `UPDATE "Survey" SET ${setClauses.join(", ")}, updated_at = NOW() WHERE id = $${paramIndex}`, + ...values + ); + } + } + + logger.info( + `Survey progress: ${Math.min(i + BATCH_SIZE, surveyUpdates.length)}/${surveyUpdates.length}` + ); + } + + logger.info(`Surveys migration complete: ${stats.surveysUpdated}/${stats.surveysProcessed} updated`); + + // ==================== MIGRATE PROJECTS ==================== + logger.info("Starting Project migration..."); + + // Use '%http%/storage/%' to match absolute URLs anywhere in the JSON text + const projectQuery = Prisma.sql` + SELECT id, styling, logo + FROM "Project" + WHERE styling::text LIKE '%http%/storage/%' + OR logo::text LIKE '%http%/storage/%' + `; + + const projectsToMigrate: ProjectRecord[] = await tx.$queryRaw(projectQuery); + logger.info(`Found ${projectsToMigrate.length} projects with storage URLs`); + + for (const project of projectsToMigrate) { + stats.projectsProcessed++; + + const updates: Partial = {}; + let hasChanges = false; + + if (containsAbsoluteStorageUrl(project.styling)) { + updates.styling = transformJsonUrls(JSON.parse(JSON.stringify(project.styling))); + hasChanges = true; + } + + if (containsAbsoluteStorageUrl(project.logo)) { + updates.logo = transformJsonUrls(JSON.parse(JSON.stringify(project.logo))); + hasChanges = true; + } + + if (hasChanges) { + const setClauses: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + if (updates.styling !== undefined) { + setClauses.push(`styling = $${paramIndex}::jsonb`); + values.push(JSON.stringify(updates.styling)); + paramIndex++; + } + if (updates.logo !== undefined) { + setClauses.push(`logo = $${paramIndex}::jsonb`); + values.push(JSON.stringify(updates.logo)); + paramIndex++; + } + + values.push(project.id); + + await tx.$executeRawUnsafe( + `UPDATE "Project" SET ${setClauses.join(", ")}, updated_at = NOW() WHERE id = $${paramIndex}`, + ...values + ); + + stats.projectsUpdated++; + } + } + + logger.info(`Projects migration complete: ${stats.projectsUpdated}/${stats.projectsProcessed} updated`); + + // ==================== MIGRATE ORGANIZATIONS ==================== + logger.info("Starting Organization migration..."); + + // Use '%http%/storage/%' to match absolute URLs anywhere in the JSON text + const orgQuery = Prisma.sql` + SELECT id, whitelabel + FROM "Organization" + WHERE whitelabel::text LIKE '%http%/storage/%' + `; + + const orgsToMigrate: OrganizationRecord[] = await tx.$queryRaw(orgQuery); + logger.info(`Found ${orgsToMigrate.length} organizations with storage URLs`); + + for (const org of orgsToMigrate) { + stats.organizationsProcessed++; + + if (containsAbsoluteStorageUrl(org.whitelabel)) { + const updatedWhitelabel = transformJsonUrls(JSON.parse(JSON.stringify(org.whitelabel))); + + await tx.$executeRawUnsafe( + `UPDATE "Organization" SET whitelabel = $1::jsonb, updated_at = NOW() WHERE id = $2`, + JSON.stringify(updatedWhitelabel), + org.id + ); + + stats.organizationsUpdated++; + } + } + + logger.info( + `Organizations migration complete: ${stats.organizationsUpdated}/${stats.organizationsProcessed} updated` + ); + + // ==================== MIGRATE RESPONSES ==================== + logger.info("Starting Response migration..."); + + // Responses can be numerous, so we process in batches using cursor pagination + let lastId: string | null = null; + let hasMore = true; + + while (hasMore) { + // Use '%http%/storage/%' to match absolute URLs anywhere in the JSON text + const responseQuery = lastId + ? Prisma.sql` + SELECT id, data + FROM "Response" + WHERE data::text LIKE '%http%/storage/%' + AND id > ${lastId} + ORDER BY id + LIMIT ${BATCH_SIZE} + ` + : Prisma.sql` + SELECT id, data + FROM "Response" + WHERE data::text LIKE '%http%/storage/%' + ORDER BY id + LIMIT ${BATCH_SIZE} + `; + + const responseBatch: ResponseRecord[] = await tx.$queryRaw(responseQuery); + + if (responseBatch.length === 0) { + hasMore = false; + break; + } + + for (const response of responseBatch) { + stats.responsesProcessed++; + + if (containsAbsoluteStorageUrl(response.data)) { + const updatedData = transformJsonUrls(JSON.parse(JSON.stringify(response.data))); + + await tx.$executeRawUnsafe( + `UPDATE "Response" SET data = $1::jsonb, updated_at = NOW() WHERE id = $2`, + JSON.stringify(updatedData), + response.id + ); + + stats.responsesUpdated++; + } + + lastId = response.id; + } + + logger.info( + `Response progress: ${stats.responsesProcessed} processed, ${stats.responsesUpdated} updated` + ); + + if (responseBatch.length < BATCH_SIZE) { + hasMore = false; + } + } + + logger.info( + `Responses migration complete: ${stats.responsesUpdated}/${stats.responsesProcessed} updated` + ); + + // ==================== FINAL STATS ==================== + stats.urlsConverted = getUrlConversionCount(); + + logger.info("=== Migration Complete ==="); + logger.info(`Surveys: ${stats.surveysUpdated}/${stats.surveysProcessed} updated`); + logger.info(`Projects: ${stats.projectsUpdated}/${stats.projectsProcessed} updated`); + logger.info(`Organizations: ${stats.organizationsUpdated}/${stats.organizationsProcessed} updated`); + logger.info(`Responses: ${stats.responsesUpdated}/${stats.responsesProcessed} updated`); + logger.info(`Total URLs converted: ${stats.urlsConverted}`); + + if (stats.errors > 0) { + logger.warn(`Errors encountered: ${stats.errors}`); + } + }, +}; diff --git a/packages/database/migration/20260204174943_migrate_storage_urls_to_relative/types.ts b/packages/database/migration/20260204174943_migrate_storage_urls_to_relative/types.ts new file mode 100644 index 0000000000..2c7f8ab64b --- /dev/null +++ b/packages/database/migration/20260204174943_migrate_storage_urls_to_relative/types.ts @@ -0,0 +1,42 @@ +/** + * Types for the storage URL migration + */ + +export interface SurveyRecord { + id: string; + welcomeCard: unknown; + questions: unknown; + blocks: unknown[]; + endings: unknown[]; + styling: unknown; + metadata: unknown; +} + +export interface ProjectRecord { + id: string; + styling: unknown; + logo: unknown; +} + +export interface OrganizationRecord { + id: string; + whitelabel: unknown; +} + +export interface ResponseRecord { + id: string; + data: unknown; +} + +export interface MigrationStats { + surveysProcessed: number; + surveysUpdated: number; + projectsProcessed: number; + projectsUpdated: number; + organizationsProcessed: number; + organizationsUpdated: number; + responsesProcessed: number; + responsesUpdated: number; + urlsConverted: number; + errors: number; +} diff --git a/packages/database/migration/20260204174943_migrate_storage_urls_to_relative/utils.ts b/packages/database/migration/20260204174943_migrate_storage_urls_to_relative/utils.ts new file mode 100644 index 0000000000..09cd55141e --- /dev/null +++ b/packages/database/migration/20260204174943_migrate_storage_urls_to_relative/utils.ts @@ -0,0 +1,98 @@ +/** + * Utility functions for converting absolute storage URLs to relative paths + */ + +// Regex to match absolute storage URLs: http(s)://anything/storage/... +const ABSOLUTE_STORAGE_URL_REGEX = /^https?:\/\/[^/]+\/storage\//; + +/** + * Convert an absolute storage URL to a relative path + * @param url The URL to convert + * @returns The relative path if it's an absolute storage URL, otherwise the original value + */ +export function convertStorageUrlToRelative(url: string): string { + if (!url || typeof url !== "string") { + return url; + } + + // Check if it's an absolute storage URL + if (ABSOLUTE_STORAGE_URL_REGEX.test(url)) { + const storageIndex = url.indexOf("/storage/"); + if (storageIndex !== -1) { + return url.substring(storageIndex); // Returns /storage/... + } + } + + return url; // Return unchanged if not an absolute storage URL +} + +/** + * Track statistics for URL conversions + */ +let urlConversionCount = 0; + +export function resetUrlConversionCount(): void { + urlConversionCount = 0; +} + +export function getUrlConversionCount(): number { + return urlConversionCount; +} + +/** + * Recursively transform all string values in an object, converting absolute storage URLs to relative paths + * @param obj The object to transform + * @returns The transformed object (mutated in place for arrays/objects) + */ +export function transformJsonUrls(obj: unknown): unknown { + if (obj === null || obj === undefined) { + return obj; + } + + if (typeof obj === "string") { + const converted = convertStorageUrlToRelative(obj); + if (converted !== obj) { + urlConversionCount++; + } + return converted; + } + + if (Array.isArray(obj)) { + return obj.map((item) => transformJsonUrls(item)); + } + + if (typeof obj === "object") { + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + result[key] = transformJsonUrls(value); + } + return result; + } + + return obj; +} + +/** + * Check if an object contains any absolute storage URLs + * @param obj The object to check + * @returns true if the object contains absolute storage URLs + */ +export function containsAbsoluteStorageUrl(obj: unknown): boolean { + if (obj === null || obj === undefined) { + return false; + } + + if (typeof obj === "string") { + return ABSOLUTE_STORAGE_URL_REGEX.test(obj); + } + + if (Array.isArray(obj)) { + return obj.some((item) => containsAbsoluteStorageUrl(item)); + } + + if (typeof obj === "object") { + return Object.values(obj as Record).some((value) => containsAbsoluteStorageUrl(value)); + } + + return false; +} diff --git a/packages/storage/.cursor/rules/storage-package.md b/packages/storage/.cursor/rules/storage-package.md index 64763645e7..c5a7c7be00 100644 --- a/packages/storage/.cursor/rules/storage-package.md +++ b/packages/storage/.cursor/rules/storage-package.md @@ -33,6 +33,27 @@ if (uploadResult.ok) { ### File Download Flow +There are two approaches for downloading files: + +#### Option 1: Streaming + +```typescript +// Stream file content directly from S3 +const streamResult = await getFileStream("users/123/avatars/user-avatar.jpg"); + +if (streamResult.ok) { + const { body, contentType, contentLength } = streamResult.data; + return new Response(body, { + headers: { + "Content-Type": contentType, + "Content-Length": String(contentLength), + }, + }); +} +``` + +#### Option 2: Presigned URL (Redirect) + ```typescript // Generate temporary download links for private files const downloadResult = await getSignedDownloadUrl("users/123/avatars/user-avatar.jpg"); @@ -43,6 +64,8 @@ if (downloadResult.ok) { } ``` +**When to use:** External clients that need direct S3 access, or when you want to offload bandwidth to S3. + ### Cleanup Operations ```typescript @@ -57,7 +80,7 @@ await deleteFilesByPrefix("surveys/456/responses/"); // Deletes all response fil ### Module Responsibilities -- **`service.ts`**: Core business logic - the four main operations +- **`service.ts`**: Core business logic - the five main operations (upload URL, download URL, streaming, delete, bulk delete) - **`client.ts`**: S3 client factory with environment validation - **`constants.ts`**: Environment variable exports (internal use only) - **`types/error.ts`**: Result type system and error definitions @@ -267,7 +290,13 @@ const s3Client = new S3Client({ **Purpose**: Generate temporary download URL for private files **Returns**: `Result` (temporary URL valid for 30 minutes) -**Use Case**: Serving private files without making S3 bucket public +**Use Case**: External clients that need direct S3 access, or offloading bandwidth to S3 + +### `getFileStream(fileKey)` + +**Purpose**: Stream file content directly from S3 +**Returns**: `Result<{ body: ReadableStream; contentType: string; contentLength: number }, StorageError>` +**Use Case**: Serving files through your server (required for Next.js Image component with relative URLs) ### `deleteFile(fileKey)` diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 007963d604..ae23f0e667 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -1,2 +1,9 @@ -export { deleteFile, getSignedDownloadUrl, getSignedUploadUrl, deleteFilesByPrefix } from "./service"; +export { + deleteFile, + getSignedDownloadUrl, + getSignedUploadUrl, + deleteFilesByPrefix, + getFileStream, + type FileStreamResult, +} from "./service"; export { type StorageError, StorageErrorCode } from "../types/error"; diff --git a/packages/storage/src/service.ts b/packages/storage/src/service.ts index 7c4717c465..1612465eca 100644 --- a/packages/storage/src/service.ts +++ b/packages/storage/src/service.ts @@ -141,6 +141,68 @@ export const getSignedDownloadUrl = async (fileKey: string): Promise; + contentType: string; + contentLength: number; +} + +/** + * Get a file stream from S3 + * Use this for streaming files directly to clients instead of redirecting to signed URLs + * @param fileKey - The key of the file in S3 + * @returns A Result containing the file stream and metadata or an error: StorageError + */ +export const getFileStream = async (fileKey: string): Promise> => { + try { + const s3Client = createS3Client(); + + if (!s3Client) { + return err({ + code: StorageErrorCode.S3ClientError, + }); + } + + if (!S3_BUCKET_NAME) { + return err({ + code: StorageErrorCode.S3CredentialsError, + }); + } + + const getObjectCommand = new GetObjectCommand({ + Bucket: S3_BUCKET_NAME, + Key: fileKey, + }); + + const response = await s3Client.send(getObjectCommand); + + if (!response.Body) { + return err({ + code: StorageErrorCode.FileNotFoundError, + }); + } + + // Convert the SDK stream to a web ReadableStream + const webStream = response.Body.transformToWebStream(); + + return ok({ + body: webStream, + contentType: response.ContentType ?? "application/octet-stream", + contentLength: response.ContentLength ?? 0, + }); + } catch (error) { + if ((error as { name?: string }).name === "NoSuchKey") { + return err({ + code: StorageErrorCode.FileNotFoundError, + }); + } + logger.error({ error }, "Failed to get file stream"); + return err({ + code: StorageErrorCode.Unknown, + }); + } +}; + /** * Delete a file from S3 * @param fileKey - The key of the file in S3 (e.g. "surveys/123/responses/456/file.pdf") diff --git a/packages/survey-ui/src/components/elements/multi-select.tsx b/packages/survey-ui/src/components/elements/multi-select.tsx index 712ebcb623..3f4512cd87 100644 --- a/packages/survey-ui/src/components/elements/multi-select.tsx +++ b/packages/survey-ui/src/components/elements/multi-select.tsx @@ -148,15 +148,15 @@ function DropdownVariant({ {options .filter((option) => option.id !== "none") diff --git a/packages/survey-ui/src/components/elements/single-select.tsx b/packages/survey-ui/src/components/elements/single-select.tsx index 381ec90f66..5d1119d99b 100644 --- a/packages/survey-ui/src/components/elements/single-select.tsx +++ b/packages/survey-ui/src/components/elements/single-select.tsx @@ -160,15 +160,15 @@ function SingleSelect({ {options @@ -193,7 +193,9 @@ function SingleSelect({ id={`${inputId}-${otherOptionId}`} dir={dir} disabled={disabled}> - {otherValue || otherOptionLabel} + + {otherValue || otherOptionLabel} + ) : null} {options @@ -279,7 +281,7 @@ function SingleSelect({ aria-required={required} /> {otherOptionLabel} diff --git a/packages/survey-ui/src/components/general/dropdown-menu.tsx b/packages/survey-ui/src/components/general/dropdown-menu.tsx index 7d84e34a7d..522e196991 100644 --- a/packages/survey-ui/src/components/general/dropdown-menu.tsx +++ b/packages/survey-ui/src/components/general/dropdown-menu.tsx @@ -29,13 +29,13 @@ function DropdownMenuContent({ data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( - "text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", + "text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md", className )} {...props} />
- + ); } @@ -58,7 +58,7 @@ function DropdownMenuItem({ data-inset={inset} data-variant={variant} className={cn( - "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", className )} {...props} @@ -76,12 +76,12 @@ function DropdownMenuCheckboxItem({ - + @@ -104,11 +104,11 @@ function DropdownMenuRadioItem({ - + @@ -175,12 +175,12 @@ function DropdownMenuSubTrigger({ data-slot="dropdown-menu-sub-trigger" data-inset={inset} className={cn( - "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus-visible:outline-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus-visible:outline-none data-[inset]:pl-8 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", className )} {...props}> {children} - + ); } diff --git a/packages/survey-ui/src/components/general/input.tsx b/packages/survey-ui/src/components/general/input.tsx index 0d042b28fb..f29e7627d8 100644 --- a/packages/survey-ui/src/components/general/input.tsx +++ b/packages/survey-ui/src/components/general/input.tsx @@ -22,7 +22,7 @@ const Input = React.forwardRef(function Input( style={{ fontSize: "var(--fb-input-font-size)" }} className={cn( // Layout and behavior - "flex min-w-0 border transition-[color,box-shadow] outline-none", + "flex min-w-0 border outline-none transition-[color,box-shadow]", // Customizable styles via CSS variables (using Tailwind theme extensions) "w-input h-input", "bg-input-bg border-input-border rounded-input", diff --git a/packages/survey-ui/src/components/general/textarea.tsx b/packages/survey-ui/src/components/general/textarea.tsx index b5323db388..0cb8ac5bd7 100644 --- a/packages/survey-ui/src/components/general/textarea.tsx +++ b/packages/survey-ui/src/components/general/textarea.tsx @@ -13,7 +13,7 @@ function Textarea({ className, dir = "auto", ...props }: TextareaProps): React.J style={{ fontSize: "var(--fb-input-font-size)" }} dir={dir} className={cn( - "w-input bg-input-bg border-input-border rounded-input font-input font-input-weight px-input-x py-input-y shadow-input placeholder:text-input-placeholder placeholder:opacity-input-placeholder focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 text-input text-input-text flex field-sizing-content min-h-16 border transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", + "w-input bg-input-bg border-input-border rounded-input font-input font-input-weight px-input-x py-input-y shadow-input placeholder:text-input-placeholder placeholder:opacity-input-placeholder focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 text-input text-input-text field-sizing-content flex min-h-16 border outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", className )} {...props} diff --git a/packages/surveys/locales/en.json b/packages/surveys/locales/en.json index 6f9354c70c..f8cf16c29b 100644 --- a/packages/surveys/locales/en.json +++ b/packages/surveys/locales/en.json @@ -21,6 +21,8 @@ "respondents_will_not_see_this_card": "Respondents will not see this card", "retry": "Retry", "retrying": "Retrying…", + "select_option": "Select an option", + "select_options": "Select options", "sending_responses": "Sending responses…", "takes_less_than_x_minutes": "{count, plural, one {Takes less than 1 minute} other {Takes less than {count} minutes}}", "takes_x_minutes": "{count, plural, one {Takes 1 minute} other {Takes {count} minutes}}", @@ -28,9 +30,7 @@ "terms_of_service": "Terms of Service", "the_servers_cannot_be_reached_at_the_moment": "The servers cannot be reached at the moment.", "they_will_be_redirected_immediately": "They will be redirected immediately", - "your_feedback_is_stuck": "Your feedback is stuck :(", - "select_option": "Select an option", - "select_options": "Select options" + "your_feedback_is_stuck": "Your feedback is stuck :(" }, "errors": { "all_options_must_be_ranked": "Please rank all options", @@ -78,4 +78,4 @@ "value_must_not_contain": "Value must not contain {value}", "value_must_not_equal": "Value must not equal {value}" } -} \ No newline at end of file +} diff --git a/packages/surveys/package.json b/packages/surveys/package.json index 035dbcf325..87a56acee0 100644 --- a/packages/surveys/package.json +++ b/packages/surveys/package.json @@ -70,4 +70,4 @@ "vite-plugin-dts": "4.5.3", "vite-tsconfig-paths": "5.1.4" } -} \ No newline at end of file +} diff --git a/packages/types/common.ts b/packages/types/common.ts index 2b486de806..9b2e08804e 100644 --- a/packages/types/common.ts +++ b/packages/types/common.ts @@ -6,6 +6,29 @@ export const ZString = z.string(); export const ZUrl = z.string().url(); +/** + * Schema for storage URLs that can be either: + * - Full URLs (http:// or https://) + * - Relative storage paths (/storage/...) + */ +export const ZStorageUrl = z.string().refine( + (val) => { + // Allow relative storage paths + if (val.startsWith("/storage/")) { + return true; + } + // Otherwise validate as URL + try { + // Using void to satisfy ESLint "no-new" rule while still validating the URL + void new URL(val); + return true; + } catch { + return false; + } + }, + { message: "Must be a valid URL or a relative storage path (/storage/...)" } +); + export const ZNumber = z.number(); export const ZOptionalNumber = z.number().optional(); diff --git a/packages/types/email.ts b/packages/types/email.ts index cad555297e..6ef3dcb4b0 100644 --- a/packages/types/email.ts +++ b/packages/types/email.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { ZStorageUrl } from "./common"; import { ZUserLocale } from "./user"; export const ZLinkSurveyEmailData = z.object({ @@ -7,7 +8,7 @@ export const ZLinkSurveyEmailData = z.object({ suId: z.string().optional(), surveyName: z.string(), locale: ZUserLocale, - logoUrl: z.string().optional(), + logoUrl: ZStorageUrl.optional(), }); export type TLinkSurveyEmailData = z.infer; diff --git a/packages/types/organizations.ts b/packages/types/organizations.ts index 1c3d65fada..a0c486aead 100644 --- a/packages/types/organizations.ts +++ b/packages/types/organizations.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { ZStorageUrl } from "./common"; export const ZOrganizationBillingPlan = z.enum(["free", "startup", "custom"]); export type TOrganizationBillingPlan = z.infer; @@ -34,8 +35,8 @@ export const ZOrganizationBilling = z.object({ export type TOrganizationBilling = z.infer; export const ZOrganizationWhitelabel = z.object({ - logoUrl: z.string().nullable(), - faviconUrl: z.string().url().nullish(), + logoUrl: ZStorageUrl.nullable(), + faviconUrl: ZStorageUrl.nullish(), }); export type TOrganizationWhitelabel = z.infer; diff --git a/packages/types/storage.ts b/packages/types/storage.ts index 9fc0ff5c40..4d3295c6d0 100644 --- a/packages/types/storage.ts +++ b/packages/types/storage.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { ZStorageUrl } from "./common"; // Single source of truth for allowed file extensions const ALLOWED_FILE_EXTENSIONS_TUPLE = [ @@ -125,7 +126,7 @@ export type TUploadPrivateFileRequest = z.infer; diff --git a/packages/types/surveys/elements.ts b/packages/types/surveys/elements.ts index 989b2630c9..ea1f415e23 100644 --- a/packages/types/surveys/elements.ts +++ b/packages/types/surveys/elements.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { ZUrl } from "../common"; +import { ZStorageUrl, ZUrl } from "../common"; import { ZI18nString } from "../i18n"; import { ZAllowedFileExtension } from "../storage"; import { TSurveyElementTypeEnum } from "./constants"; @@ -61,8 +61,8 @@ export const ZSurveyElementBase = z.object({ type: z.nativeEnum(TSurveyElementTypeEnum), headline: ZI18nString, subheader: ZI18nString.optional(), - imageUrl: ZUrl.optional(), - videoUrl: ZUrl.optional(), + imageUrl: ZStorageUrl.optional(), + videoUrl: ZStorageUrl.optional(), required: z.boolean(), scale: z.enum(["number", "smiley", "star"]).optional(), range: z.union([z.literal(5), z.literal(3), z.literal(4), z.literal(7), z.literal(10)]).optional(), @@ -237,7 +237,7 @@ export type TSurveyRatingElement = z.infer; // Picture Selection Element export const ZSurveyPictureChoice = z.object({ id: z.string(), - imageUrl: z.string(), + imageUrl: ZStorageUrl, }); export type TSurveyPictureChoice = z.infer; diff --git a/packages/types/surveys/types.ts b/packages/types/surveys/types.ts index 4998cd6699..08566f379e 100644 --- a/packages/types/surveys/types.ts +++ b/packages/types/surveys/types.ts @@ -1,7 +1,7 @@ import { type ZodIssue, z } from "zod"; import { ZSurveyFollowUp } from "@formbricks/database/types/survey-follow-up"; import { ZActionClass, ZActionClassNoCodeConfig } from "../action-classes"; -import { ZColor, ZEndingCardUrl, ZId, ZOverlay, ZPlacement, ZUrl, getZSafeUrl } from "../common"; +import { ZColor, ZEndingCardUrl, ZId, ZOverlay, ZPlacement, ZStorageUrl, getZSafeUrl } from "../common"; import { ZContactAttributes } from "../contact-attribute"; import { type TI18nString, ZI18nString } from "../i18n"; import { ZLanguage } from "../project"; @@ -60,8 +60,8 @@ export const ZSurveyEndScreenCard = ZSurveyEndingBase.extend({ subheader: ZI18nString.optional(), buttonLabel: ZI18nString.optional(), buttonLink: ZEndingCardUrl.optional(), - imageUrl: ZUrl.optional(), - videoUrl: ZUrl.optional(), + imageUrl: ZStorageUrl.optional(), + videoUrl: ZStorageUrl.optional(), }); export type TSurveyEndScreenCard = z.infer; @@ -142,11 +142,11 @@ export const ZSurveyWelcomeCard = z enabled: z.boolean(), headline: ZI18nString.optional(), subheader: ZI18nString.optional(), - fileUrl: ZUrl.optional(), + fileUrl: ZStorageUrl.optional(), buttonLabel: ZI18nString.optional(), timeToFinish: z.boolean().default(true), showResponseCount: z.boolean().default(false), - videoUrl: ZUrl.optional(), + videoUrl: ZStorageUrl.optional(), }) .refine((schema) => !(schema.enabled && !schema.headline), { message: "Welcome card must have a headline", @@ -277,7 +277,7 @@ export type TSurveyRecaptcha = z.infer; export const ZSurveyMetadata = z.object({ title: ZI18nString.optional(), description: ZI18nString.optional(), - ogImage: z.string().url().optional(), + ogImage: ZStorageUrl.optional(), }); export type TSurveyMetadata = z.infer; @@ -289,7 +289,7 @@ export const ZSurveyQuestionChoice = z.object({ export const ZSurveyPictureChoice = z.object({ id: z.string(), - imageUrl: z.string(), + imageUrl: ZStorageUrl, }); export type TSurveyPictureChoice = z.infer; @@ -405,8 +405,8 @@ export const ZSurveyQuestionBase = z.object({ type: z.string(), headline: ZI18nString, subheader: ZI18nString.optional(), - imageUrl: z.string().optional(), - videoUrl: z.string().optional(), + imageUrl: ZStorageUrl.optional(), + videoUrl: ZStorageUrl.optional(), required: z.boolean(), buttonLabel: ZI18nString.optional(), backButtonLabel: ZI18nString.optional(), @@ -3932,7 +3932,7 @@ export const ZSurveyElementSummaryPictureSelection = z.object({ choices: z.array( z.object({ id: z.string(), - imageUrl: z.string(), + imageUrl: ZStorageUrl, count: z.number(), percentage: z.number(), }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa2788291c..8ac22c2b30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7663,16 +7663,18 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} @@ -10378,7 +10380,7 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me tarn@3.0.2: resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==}