Compare commits

...

11 Commits

Author SHA1 Message Date
pandeymangg
896b9759e6 fixes unit test 2026-02-09 14:33:52 +05:30
pandeymangg
030532d6e0 fixes 2026-02-09 14:19:26 +05:30
pandeymangg
63a1a77f67 fix: feedback and email preview fixes 2026-02-09 13:57:36 +05:30
pandeymangg
494f829bde Merge branch 'fix/allow-images-local-ip' of https://github.com/formbricks/formbricks into fix/allow-images-local-ip 2026-02-09 11:27:24 +05:30
pandeymangg
68626291ed fix: migration url regex 2026-02-09 11:13:27 +05:30
Dhruwang
456960f3d5 fix: build 2026-02-06 13:45:04 +05:30
Dhruwang
f862c24698 Merge branch 'main' of https://github.com/formbricks/formbricks into fix/allow-images-local-ip 2026-02-06 13:43:06 +05:30
pandeymangg
3f35b74289 poc for relative urls 2026-02-04 18:50:24 +05:30
pandeymangg
39b9416206 checks 2026-02-02 13:00:59 +05:30
pandeymangg
37f2b275f0 restore 2026-02-02 12:47:43 +05:30
pandeymangg
0eb30dff9b allows local ip images 2026-02-02 12:41:43 +05:30
44 changed files with 923 additions and 275 deletions

View File

@@ -384,24 +384,24 @@ export const generateResponseTableColumns = (
const hiddenFieldColumns: ColumnDef<TResponseTableData>[] = survey.hiddenFields.fieldIds
? survey.hiddenFields.fieldIds.map((hiddenFieldId) => {
return {
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
header: () => (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">
<EyeOffIcon className="h-4 w-4" />
</span>
<span className="truncate">{hiddenFieldId}</span>
</div>
),
cell: ({ row }) => {
const hiddenFieldResponse = row.original.responseData[hiddenFieldId];
if (typeof hiddenFieldResponse === "string") {
return <div className="text-slate-900">{hiddenFieldResponse}</div>;
}
},
};
})
return {
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
header: () => (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">
<EyeOffIcon className="h-4 w-4" />
</span>
<span className="truncate">{hiddenFieldId}</span>
</div>
),
cell: ({ row }) => {
const hiddenFieldResponse = row.original.responseData[hiddenFieldId];
if (typeof hiddenFieldResponse === "string") {
return <div className="text-slate-900">{hiddenFieldResponse}</div>;
}
},
};
})
: [];
const metadataColumns = getMetadataColumnsData(t);

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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<boolean> => {
validateInputs([organizationId, ZId], [faviconUrl, ZUrl.nullable()]);
validateInputs([organizationId, ZId], [faviconUrl, ZStorageUrl.nullable()]);
try {
const organization = await prisma.organization.findUnique({

View File

@@ -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({
<Img
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
key={choice.id}
src={choice.imageUrl}
src={resolveStorageUrl(choice.imageUrl)}
/>
) : (
<Link
@@ -316,7 +317,7 @@ export async function PreviewEmailTemplate({
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id}
target="_blank">
<Img className="rounded-custom h-full w-full" src={choice.imageUrl} />
<Img className="rounded-custom h-full w-full" src={resolveStorageUrl(choice.imageUrl)} />
</Link>
)
)}

View File

@@ -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<boolean> => {
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<boolean> => {
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 => {

View File

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

View File

@@ -2,26 +2,13 @@ import { randomUUID } from "crypto";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { StorageErrorCode } from "@formbricks/storage";
import { TAccessType } from "@formbricks/types/storage";
import {
deleteFile,
deleteFilesByEnvironmentId,
getSignedUrlForDownload,
getSignedUrlForUpload,
} from "./service";
import { deleteFile, deleteFilesByEnvironmentId, getSignedUrlForUpload } from "./service";
// Mock external dependencies
vi.mock("crypto", () => ({
randomUUID: vi.fn(),
}));
vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "https://webapp.example.com",
}));
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
@@ -44,7 +31,6 @@ vi.mock("@formbricks/storage", () => ({
// Import mocked dependencies
const { logger } = await import("@formbricks/logger");
const { getPublicDomain } = await import("@/lib/getPublicUrl");
const {
deleteFile: deleteFileFromS3,
deleteFilesByPrefix,
@@ -63,7 +49,6 @@ describe("storage service", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(randomUUID).mockReturnValue(mockUUID);
vi.mocked(getPublicDomain).mockReturnValue("https://public.example.com");
});
describe("getSignedUrlForUpload", () => {
@@ -90,7 +75,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 +87,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 +107,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 +132,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 +257,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 = {

View File

@@ -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<Result<string, StorageError>> => {
): Promise<Result<FileStreamResult, StorageError>> => {
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,

View File

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

View File

@@ -7,6 +7,7 @@ import {
isAllowedFileExtension,
isValidFileTypeForExtension,
isValidImageFile,
resolveStorageUrl,
sanitizeFileName,
validateFileUploads,
validateSingleFile,
@@ -145,7 +146,8 @@ describe("storage utils", () => {
const { getOriginalFileNameFromUrl } =
await vi.importActual<typeof import("@/modules/storage/utils")>("@/modules/storage/utils");
const path = "/storage/env/public/Document%20Name.pdf";
expect(getOriginalFileNameFromUrl(path)).toBe("/storage/env/public/Document Name.pdf");
// 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<typeof import("@/modules/storage/utils")>("@/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");
});
});
});

View File

@@ -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 };
/**
* 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;
};

View File

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

View File

@@ -89,7 +89,7 @@ export const SurveyPlacementCard = ({
asChild
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pr-5 pl-2">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"

View File

@@ -7,12 +7,14 @@ import {
renderFollowUpEmail,
} from "@formbricks/email";
import { TResponse } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL, TERMS_URL } from "@/lib/constants";
import { getElementResponseMapping } from "@/lib/responses";
import { parseRecallInfo } from "@/lib/utils/recall";
import { getTranslate } from "@/lingodotdev/server";
import { sendEmail } from "@/modules/email";
import { resolveStorageUrl } from "@/modules/storage/utils";
export const sendFollowUpEmail = async ({
followUp,
@@ -57,12 +59,27 @@ export const sendFollowUpEmail = async ({
});
// Process response data
// Resolve relative storage URLs to absolute URLs for email rendering
const responseData: ProcessedResponseElement[] = attachResponseData
? getElementResponseMapping(survey, response).map((e) => ({
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

View File

@@ -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[];

View File

@@ -187,7 +187,7 @@ export const ThemeStylingPreviewSurvey = ({
ContentRef={ContentRef as React.MutableRefObject<HTMLDivElement> | null}
isEditorView>
{!project.styling?.isLogoHidden && (
<button className="absolute top-5 left-5" onClick={scrollToEditLogoSection}>
<button className="absolute left-5 top-5" onClick={scrollToEditLogoSection}>
<ClientLogo projectLogo={project.logo} previewSurvey />
</button>
)}

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -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,

View File

@@ -447,9 +447,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
@@ -474,9 +474,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
@@ -494,9 +494,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
@@ -518,9 +518,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
@@ -542,9 +542,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
@@ -562,9 +562,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
@@ -582,9 +582,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
@@ -616,9 +616,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
@@ -658,9 +658,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
@@ -688,9 +688,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

View File

@@ -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

View File

@@ -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<SurveyRecord> }[] = [];
for (const survey of surveysToMigrate) {
stats.surveysProcessed++;
const updates: Partial<SurveyRecord> = {};
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<ProjectRecord> = {};
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}`);
}
},
};

View File

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

View File

@@ -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<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
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<string, unknown>).some((value) => containsAbsoluteStorageUrl(value));
}
return false;
}

View File

@@ -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<string, StorageError>` (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<Uint8Array>; contentType: string; contentLength: number }, StorageError>`
**Use Case**: Serving files through your server (required for Next.js Image component with relative URLs)
### `deleteFile(fileKey)`

View File

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

View File

@@ -141,6 +141,68 @@ export const getSignedDownloadUrl = async (fileKey: string): Promise<Result<stri
}
};
export interface FileStreamResult {
body: ReadableStream<Uint8Array>;
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<Result<FileStreamResult, StorageError>> => {
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")

View File

@@ -148,15 +148,15 @@ function DropdownVariant({
<Button
variant="outline"
disabled={disabled}
className="rounded-input w-full justify-between bg-option-bg rounded-option border border-option-border my-0 h-input"
className="rounded-input bg-option-bg rounded-option border-option-border h-input my-0 w-full justify-between border"
aria-invalid={Boolean(errorMessage)}
aria-label={headline}>
<span className="truncate font-input font-input-weight text-input-text">{displayText}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50 label-headline" />
<span className="font-input font-input-weight text-input-text truncate">{displayText}</span>
<ChevronDown className="label-headline ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="bg-option-bg w-[var(--radix-dropdown-menu-trigger-width)] max-h-[300px] overflow-y-auto"
className="bg-option-bg max-h-[300px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto"
align="start">
{options
.filter((option) => option.id !== "none")

View File

@@ -160,15 +160,15 @@ function SingleSelect({
<Button
variant="outline"
disabled={disabled}
className="rounded-input w-full justify-between bg-option-bg rounded-option border border-option-border my-0 h-input"
className="rounded-input bg-option-bg rounded-option border-option-border h-input my-0 w-full justify-between border"
aria-invalid={Boolean(errorMessage)}
aria-label={headline}>
<span className="truncate font-input font-input-weight text-input-text">{displayText}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50 label-headline" />
<span className="font-input font-input-weight text-input-text truncate">{displayText}</span>
<ChevronDown className="label-headline ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="bg-option-bg w-[var(--radix-dropdown-menu-trigger-width)] max-h-[300px] overflow-y-auto"
className="bg-option-bg max-h-[300px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto"
align="start">
<DropdownMenuRadioGroup value={selectedValue} onValueChange={onChange}>
{options
@@ -193,7 +193,9 @@ function SingleSelect({
id={`${inputId}-${otherOptionId}`}
dir={dir}
disabled={disabled}>
<span className="font-input font-input-weight text-input-text">{otherValue || otherOptionLabel}</span>
<span className="font-input font-input-weight text-input-text">
{otherValue || otherOptionLabel}
</span>
</DropdownMenuRadioItem>
) : null}
{options
@@ -279,7 +281,7 @@ function SingleSelect({
aria-required={required}
/>
<span
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
className={cn("ml-3 mr-3 grow", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}>
{otherOptionLabel}
</span>

View File

@@ -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}
/>
</div>
</DropdownMenuPrimitive.Portal >
</DropdownMenuPrimitive.Portal>
);
}
@@ -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({
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_*]:cursor-pointer",
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_*]:cursor-pointer [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
{...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center label-headline">
<span className="label-headline pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
@@ -104,11 +104,11 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_*]:cursor-pointer",
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_*]:cursor-pointer [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center label-headline">
<span className="label-headline pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
@@ -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}
<ChevronRightIcon className="ml-auto label-headline size-4" />
<ChevronRightIcon className="label-headline ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}

View File

@@ -22,7 +22,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(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",

View File

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

View File

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

View File

@@ -70,4 +70,4 @@
"vite-plugin-dts": "4.5.3",
"vite-tsconfig-paths": "5.1.4"
}
}
}

View File

@@ -6,6 +6,28 @@ 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 {
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();

View File

@@ -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<typeof ZLinkSurveyEmailData>;

View File

@@ -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<typeof ZOrganizationBillingPlan>;
@@ -34,8 +35,8 @@ export const ZOrganizationBilling = z.object({
export type TOrganizationBilling = z.infer<typeof ZOrganizationBilling>;
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<typeof ZOrganizationWhitelabel>;

View File

@@ -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<typeof ZUploadPrivateFileRequest
export const ZUploadFileResponse = z.object({
data: z.object({
signedUrl: z.string(),
fileUrl: z.string(),
fileUrl: ZStorageUrl,
signingData: z
.object({
signature: z.string(),

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { ZColor } from "./common";
import { ZColor, ZStorageUrl } from "./common";
export const ZStylingColor = z.object({
light: ZColor,
@@ -16,7 +16,7 @@ export const ZCardArrangement = z.object({
});
export const ZLogo = z.object({
url: z.string().optional(),
url: ZStorageUrl.optional(),
bgColor: z.string().optional(),
});
export type TLogo = z.infer<typeof ZLogo>;

View File

@@ -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<typeof ZSurveyRatingElement>;
// Picture Selection Element
export const ZSurveyPictureChoice = z.object({
id: z.string(),
imageUrl: z.string(),
imageUrl: ZStorageUrl,
});
export type TSurveyPictureChoice = z.infer<typeof ZSurveyPictureChoice>;

View File

@@ -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<typeof ZSurveyEndScreenCard>;
@@ -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<typeof ZSurveyRecaptcha>;
export const ZSurveyMetadata = z.object({
title: ZI18nString.optional(),
description: ZI18nString.optional(),
ogImage: z.string().url().optional(),
ogImage: ZStorageUrl.optional(),
});
export type TSurveyMetadata = z.infer<typeof ZSurveyMetadata>;
@@ -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<typeof ZSurveyPictureChoice>;
@@ -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(),
})

6
pnpm-lock.yaml generated
View File

@@ -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==}