mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-22 17:32:05 -05:00
Compare commits
5 Commits
fix-lingo-
...
4.8.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
913ab98d62 | ||
|
|
717a172ce0 | ||
|
|
8c935f20c2 | ||
|
|
a10404ba1d | ||
|
|
39788ce0e1 |
@@ -1,7 +1,19 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { type Instrumentation } from "next";
|
||||||
|
import { isExpectedError } from "@formbricks/types/errors";
|
||||||
import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@/lib/constants";
|
import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@/lib/constants";
|
||||||
|
|
||||||
export const onRequestError = Sentry.captureRequestError;
|
export const onRequestError: Instrumentation.onRequestError = (...args) => {
|
||||||
|
const [error] = args;
|
||||||
|
|
||||||
|
// Skip expected business-logic errors (AuthorizationError, ResourceNotFoundError, etc.)
|
||||||
|
// These are handled gracefully in the UI and don't need server-side Sentry reporting
|
||||||
|
if (error instanceof Error && isExpectedError(error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sentry.captureRequestError(...args);
|
||||||
|
};
|
||||||
|
|
||||||
export const register = async () => {
|
export const register = async () => {
|
||||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const TagsLoading = () => {
|
|||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle={t("common.workspace_configuration")}>
|
<PageHeader pageTitle={t("common.workspace_configuration")}>
|
||||||
<ProjectConfigNavigation activeId="tags" />
|
<ProjectConfigNavigation activeId="tags" loading />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title={t("environments.workspace.tags.manage_tags")}
|
title={t("environments.workspace.tags.manage_tags")}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Logger } from "@/lib/common/logger";
|
|||||||
import { getIsSetup, setIsSetup } from "@/lib/common/status";
|
import { getIsSetup, setIsSetup } from "@/lib/common/status";
|
||||||
import { filterSurveys, getIsDebug, isNowExpired, wrapThrows } from "@/lib/common/utils";
|
import { filterSurveys, getIsDebug, isNowExpired, wrapThrows } from "@/lib/common/utils";
|
||||||
import { fetchEnvironmentState } from "@/lib/environment/state";
|
import { fetchEnvironmentState } from "@/lib/environment/state";
|
||||||
import { closeSurvey } from "@/lib/survey/widget";
|
import { closeSurvey, preloadSurveysScript } from "@/lib/survey/widget";
|
||||||
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
|
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
|
||||||
import { sendUpdatesToBackend } from "@/lib/user/update";
|
import { sendUpdatesToBackend } from "@/lib/user/update";
|
||||||
import {
|
import {
|
||||||
@@ -316,6 +316,9 @@ export const setup = async (
|
|||||||
addEventListeners();
|
addEventListeners();
|
||||||
addCleanupEventListeners();
|
addCleanupEventListeners();
|
||||||
|
|
||||||
|
// Preload surveys script so it's ready when a survey triggers
|
||||||
|
preloadSurveysScript(configInput.appUrl);
|
||||||
|
|
||||||
setIsSetup(true);
|
setIsSetup(true);
|
||||||
logger.debug("Set up complete");
|
logger.debug("Set up complete");
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
handleHiddenFields,
|
handleHiddenFields,
|
||||||
shouldDisplayBasedOnPercentage,
|
shouldDisplayBasedOnPercentage,
|
||||||
} from "@/lib/common/utils";
|
} from "@/lib/common/utils";
|
||||||
|
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||||
import { type TEnvironmentStateSurvey, type TUserState } from "@/types/config";
|
import { type TEnvironmentStateSurvey, type TUserState } from "@/types/config";
|
||||||
import { type TTrackProperties } from "@/types/survey";
|
import { type TTrackProperties } from "@/types/survey";
|
||||||
|
|
||||||
@@ -60,6 +61,24 @@ export const renderWidget = async (
|
|||||||
|
|
||||||
setIsSurveyRunning(true);
|
setIsSurveyRunning(true);
|
||||||
|
|
||||||
|
// Wait for pending user identification to complete before rendering
|
||||||
|
const updateQueue = UpdateQueue.getInstance();
|
||||||
|
if (updateQueue.hasPendingWork()) {
|
||||||
|
logger.debug("Waiting for pending user identification before rendering survey");
|
||||||
|
const identificationSucceeded = await updateQueue.waitForPendingWork();
|
||||||
|
if (!identificationSucceeded) {
|
||||||
|
const hasSegmentFilters = Array.isArray(survey.segment?.filters) && survey.segment.filters.length > 0;
|
||||||
|
|
||||||
|
if (hasSegmentFilters) {
|
||||||
|
logger.debug("User identification failed. Skipping survey with segment filters.");
|
||||||
|
setIsSurveyRunning(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("User identification failed but survey has no segment filters. Proceeding.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (survey.delay) {
|
if (survey.delay) {
|
||||||
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay.toString()} seconds.`);
|
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay.toString()} seconds.`);
|
||||||
}
|
}
|
||||||
@@ -87,7 +106,15 @@ export const renderWidget = async (
|
|||||||
const overlay = projectOverwrites.overlay ?? project.overlay;
|
const overlay = projectOverwrites.overlay ?? project.overlay;
|
||||||
const placement = projectOverwrites.placement ?? project.placement;
|
const placement = projectOverwrites.placement ?? project.placement;
|
||||||
const isBrandingEnabled = project.inAppSurveyBranding;
|
const isBrandingEnabled = project.inAppSurveyBranding;
|
||||||
const formbricksSurveys = await loadFormbricksSurveysExternally();
|
|
||||||
|
let formbricksSurveys: TFormbricksSurveys;
|
||||||
|
try {
|
||||||
|
formbricksSurveys = await loadFormbricksSurveysExternally();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to load surveys library: ${String(error)}`);
|
||||||
|
setIsSurveyRunning(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const recaptchaSiteKey = config.get().environment.data.recaptchaSiteKey;
|
const recaptchaSiteKey = config.get().environment.data.recaptchaSiteKey;
|
||||||
const isSpamProtectionEnabled = Boolean(recaptchaSiteKey && survey.recaptcha?.enabled);
|
const isSpamProtectionEnabled = Boolean(recaptchaSiteKey && survey.recaptcha?.enabled);
|
||||||
@@ -200,30 +227,87 @@ export const removeWidgetContainer = (): void => {
|
|||||||
document.getElementById(CONTAINER_ID)?.remove();
|
document.getElementById(CONTAINER_ID)?.remove();
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadFormbricksSurveysExternally = (): Promise<typeof globalThis.window.formbricksSurveys> => {
|
const SURVEYS_LOAD_TIMEOUT_MS = 10000;
|
||||||
const config = Config.getInstance();
|
const SURVEYS_POLL_INTERVAL_MS = 200;
|
||||||
|
|
||||||
|
type TFormbricksSurveys = typeof globalThis.window.formbricksSurveys;
|
||||||
|
|
||||||
|
let surveysLoadPromise: Promise<TFormbricksSurveys> | null = null;
|
||||||
|
|
||||||
|
const waitForSurveysGlobal = (): Promise<TFormbricksSurveys> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- We need to check if the formbricksSurveys object exists
|
const startTime = Date.now();
|
||||||
if (globalThis.window.formbricksSurveys) {
|
|
||||||
resolve(globalThis.window.formbricksSurveys);
|
const check = (): void => {
|
||||||
} else {
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||||
const script = document.createElement("script");
|
if (globalThis.window.formbricksSurveys) {
|
||||||
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
|
|
||||||
script.async = true;
|
|
||||||
script.onload = () => {
|
|
||||||
// Apply stored nonce if it was set before surveys package loaded
|
|
||||||
const storedNonce = globalThis.window.__formbricksNonce;
|
const storedNonce = globalThis.window.__formbricksNonce;
|
||||||
if (storedNonce) {
|
if (storedNonce) {
|
||||||
globalThis.window.formbricksSurveys.setNonce(storedNonce);
|
globalThis.window.formbricksSurveys.setNonce(storedNonce);
|
||||||
}
|
}
|
||||||
resolve(globalThis.window.formbricksSurveys);
|
resolve(globalThis.window.formbricksSurveys);
|
||||||
};
|
return;
|
||||||
script.onerror = (error) => {
|
}
|
||||||
console.error("Failed to load Formbricks Surveys library:", error);
|
|
||||||
reject(new Error(`Failed to load Formbricks Surveys library: ${error as string}`));
|
if (Date.now() - startTime >= SURVEYS_LOAD_TIMEOUT_MS) {
|
||||||
};
|
reject(new Error("Formbricks Surveys library did not become available within timeout"));
|
||||||
document.head.appendChild(script);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTimeout(check, SURVEYS_POLL_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
check();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadFormbricksSurveysExternally = (): Promise<TFormbricksSurveys> => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||||
|
if (globalThis.window.formbricksSurveys) {
|
||||||
|
return Promise.resolve(globalThis.window.formbricksSurveys);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (surveysLoadPromise) {
|
||||||
|
return surveysLoadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
surveysLoadPromise = new Promise<TFormbricksSurveys>((resolve, reject: (error: unknown) => void) => {
|
||||||
|
const config = Config.getInstance();
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
|
||||||
|
script.async = true;
|
||||||
|
script.onload = () => {
|
||||||
|
waitForSurveysGlobal()
|
||||||
|
.then(resolve)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
surveysLoadPromise = null;
|
||||||
|
console.error("Failed to load Formbricks Surveys library:", error);
|
||||||
|
reject(new Error(`Failed to load Formbricks Surveys library`));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
script.onerror = (error) => {
|
||||||
|
surveysLoadPromise = null;
|
||||||
|
console.error("Failed to load Formbricks Surveys library:", error);
|
||||||
|
reject(new Error(`Failed to load Formbricks Surveys library`));
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
return surveysLoadPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
let isPreloaded = false;
|
||||||
|
|
||||||
|
export const preloadSurveysScript = (appUrl: string): void => {
|
||||||
|
// Don't preload if already loaded or already preloading
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||||
|
if (globalThis.window.formbricksSurveys) return;
|
||||||
|
if (isPreloaded) return;
|
||||||
|
|
||||||
|
isPreloaded = true;
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.rel = "preload";
|
||||||
|
link.as = "script";
|
||||||
|
link.href = `${appUrl}/js/surveys.umd.cjs`;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ export class UpdateQueue {
|
|||||||
private static instance: UpdateQueue | null = null;
|
private static instance: UpdateQueue | null = null;
|
||||||
private updates: TUpdates | null = null;
|
private updates: TUpdates | null = null;
|
||||||
private debounceTimeout: NodeJS.Timeout | null = null;
|
private debounceTimeout: NodeJS.Timeout | null = null;
|
||||||
|
private pendingFlush: Promise<void> | null = null;
|
||||||
private readonly DEBOUNCE_DELAY = 500;
|
private readonly DEBOUNCE_DELAY = 500;
|
||||||
|
private readonly PENDING_WORK_TIMEOUT = 5000;
|
||||||
|
|
||||||
private constructor() {}
|
private constructor() {}
|
||||||
|
|
||||||
@@ -63,17 +65,45 @@ export class UpdateQueue {
|
|||||||
return !this.updates;
|
return !this.updates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public hasPendingWork(): boolean {
|
||||||
|
return this.updates !== null || this.pendingFlush !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async waitForPendingWork(): Promise<boolean> {
|
||||||
|
if (!this.hasPendingWork()) return true;
|
||||||
|
|
||||||
|
const flush = this.pendingFlush ?? this.processUpdates();
|
||||||
|
try {
|
||||||
|
const succeeded = await Promise.race([
|
||||||
|
flush.then(() => true as const),
|
||||||
|
new Promise<false>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(false);
|
||||||
|
}, this.PENDING_WORK_TIMEOUT);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return succeeded;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async processUpdates(): Promise<void> {
|
public async processUpdates(): Promise<void> {
|
||||||
const logger = Logger.getInstance();
|
const logger = Logger.getInstance();
|
||||||
if (!this.updates) {
|
if (!this.updates) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a flush is already in flight, reuse it instead of creating a new promise
|
||||||
|
if (this.pendingFlush) {
|
||||||
|
return this.pendingFlush;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.debounceTimeout) {
|
if (this.debounceTimeout) {
|
||||||
clearTimeout(this.debounceTimeout);
|
clearTimeout(this.debounceTimeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
const flushPromise = new Promise<void>((resolve, reject) => {
|
||||||
const handler = async (): Promise<void> => {
|
const handler = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
let currentUpdates = { ...this.updates };
|
let currentUpdates = { ...this.updates };
|
||||||
@@ -147,8 +177,10 @@ export class UpdateQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.clearUpdates();
|
this.clearUpdates();
|
||||||
|
this.pendingFlush = null;
|
||||||
resolve();
|
resolve();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
this.pendingFlush = null;
|
||||||
logger.error(
|
logger.error(
|
||||||
`Failed to process updates: ${error instanceof Error ? error.message : "Unknown error"}`
|
`Failed to process updates: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||||
);
|
);
|
||||||
@@ -158,5 +190,8 @@ export class UpdateQueue {
|
|||||||
|
|
||||||
this.debounceTimeout = setTimeout(() => void handler(), this.DEBOUNCE_DELAY);
|
this.debounceTimeout = setTimeout(() => void handler(), this.DEBOUNCE_DELAY);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.pendingFlush = flushPromise;
|
||||||
|
return flushPromise;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import DOMPurify from "isomorphic-dompurify";
|
import { sanitize } from "isomorphic-dompurify";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn, stripInlineStyles } from "@/lib/utils";
|
import { cn, stripInlineStyles } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ function Label({
|
|||||||
const isHtml = childrenString ? isValidHTML(strippedContent) : false;
|
const isHtml = childrenString ? isValidHTML(strippedContent) : false;
|
||||||
const safeHtml =
|
const safeHtml =
|
||||||
isHtml && strippedContent
|
isHtml && strippedContent
|
||||||
? DOMPurify.sanitize(strippedContent, {
|
? sanitize(strippedContent, {
|
||||||
ADD_ATTR: ["target"],
|
ADD_ATTR: ["target"],
|
||||||
FORBID_ATTR: ["style"],
|
FORBID_ATTR: ["style"],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type ClassValue, clsx } from "clsx";
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import DOMPurify from "isomorphic-dompurify";
|
import { sanitize } from "isomorphic-dompurify";
|
||||||
import { extendTailwindMerge } from "tailwind-merge";
|
import { extendTailwindMerge } from "tailwind-merge";
|
||||||
|
|
||||||
const twMerge = extendTailwindMerge({
|
const twMerge = extendTailwindMerge({
|
||||||
@@ -27,14 +27,16 @@ export function cn(...inputs: ClassValue[]): string {
|
|||||||
export const stripInlineStyles = (html: string): string => {
|
export const stripInlineStyles = (html: string): string => {
|
||||||
if (!html) return html;
|
if (!html) return html;
|
||||||
|
|
||||||
// Use DOMPurify to safely remove style attributes
|
// Pre-strip style attributes from the raw string BEFORE DOMPurify parses it.
|
||||||
// This is more secure than regex-based approaches and handles edge cases properly
|
// DOMPurify internally uses innerHTML to parse HTML, which triggers CSP
|
||||||
return DOMPurify.sanitize(html, {
|
// `style-src` violations at parse time — before FORBID_ATTR can strip them.
|
||||||
|
// The regex is O(n) safe: [^"]* and [^']* are negated classes bounded by
|
||||||
|
// fixed quote delimiters, so no backtracking can occur.
|
||||||
|
const preStripped = html.replaceAll(/ style="[^"]*"| style='[^']*'/gi, "");
|
||||||
|
|
||||||
|
return sanitize(preStripped, {
|
||||||
FORBID_ATTR: ["style"],
|
FORBID_ATTR: ["style"],
|
||||||
// Preserve the target attribute (e.g. target="_blank" on links) which is not
|
|
||||||
// in DOMPurify's default allow-list but is explicitly required downstream.
|
|
||||||
ADD_ATTR: ["target"],
|
ADD_ATTR: ["target"],
|
||||||
// Keep other attributes and tags as-is, only remove style attributes
|
|
||||||
KEEP_CONTENT: true,
|
KEEP_CONTENT: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2020", "ES2021.String"],
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
|
|||||||
@@ -10,14 +10,16 @@ import DOMPurify from "isomorphic-dompurify";
|
|||||||
export const stripInlineStyles = (html: string): string => {
|
export const stripInlineStyles = (html: string): string => {
|
||||||
if (!html) return html;
|
if (!html) return html;
|
||||||
|
|
||||||
// Use DOMPurify to safely remove style attributes
|
// Pre-strip style attributes from the raw string BEFORE DOMPurify parses it.
|
||||||
// This is more secure than regex-based approaches and handles edge cases properly
|
// DOMPurify internally uses innerHTML to parse HTML, which triggers CSP
|
||||||
return DOMPurify.sanitize(html, {
|
// `style-src` violations at parse time — before FORBID_ATTR can strip them.
|
||||||
|
// The regex is O(n) safe: [^"]* and [^']* are negated classes bounded by
|
||||||
|
// fixed quote delimiters, so no backtracking can occur.
|
||||||
|
const preStripped = html.replaceAll(/ style="[^"]*"| style='[^']*'/gi, "");
|
||||||
|
|
||||||
|
return DOMPurify.sanitize(preStripped, {
|
||||||
FORBID_ATTR: ["style"],
|
FORBID_ATTR: ["style"],
|
||||||
// Preserve the target attribute (e.g. target="_blank" on links) which is not
|
|
||||||
// in DOMPurify's default allow-list but is explicitly required downstream.
|
|
||||||
ADD_ATTR: ["target"],
|
ADD_ATTR: ["target"],
|
||||||
// Keep other attributes and tags as-is, only remove style attributes
|
|
||||||
KEEP_CONTENT: true,
|
KEEP_CONTENT: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user