mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-09 18:58:46 -06:00
Compare commits
14 Commits
typeerror-
...
fix/allow-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
442a1c9022 | ||
|
|
5d13d03513 | ||
|
|
1937efdfc0 | ||
|
|
896b9759e6 | ||
|
|
030532d6e0 | ||
|
|
63a1a77f67 | ||
|
|
494f829bde | ||
|
|
68626291ed | ||
|
|
456960f3d5 | ||
|
|
f862c24698 | ||
|
|
3f35b74289 | ||
|
|
39b9416206 | ||
|
|
37f2b275f0 | ||
|
|
0eb30dff9b |
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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<ReturnType<typeof getSignedUploadUrl>>;
|
||||
type MockedSignedDownloadReturn = Awaited<ReturnType<typeof getSignedDownloadUrl>>;
|
||||
type MockedFileStreamReturn = Awaited<ReturnType<typeof getFileStream>>;
|
||||
type MockedDeleteFileReturn = Awaited<ReturnType<typeof deleteFile>>;
|
||||
type MockedDeleteFilesByPrefixReturn = Awaited<ReturnType<typeof deleteFilesByPrefix>>;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
30
apps/web/modules/storage/url-helpers.ts
Normal file
30
apps/web/modules/storage/url-helpers.ts
Normal 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 "";
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)`
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,4 +70,4 @@
|
||||
"vite-plugin-dts": "4.5.3",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
6
pnpm-lock.yaml
generated
@@ -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==}
|
||||
|
||||
Reference in New Issue
Block a user