diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/(setup)/app-connection/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/(setup)/app-connection/page.tsx index 3eb6eac8d3bb..4d6ca6fab0e6 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/(setup)/app-connection/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/(setup)/app-connection/page.tsx @@ -1,3 +1,6 @@ +import { getSettingsPageMetadata } from "@/modules/settings/lib/metadata"; import { AppConnectionPage } from "@/modules/workspaces/settings/(setup)/app-connection/page"; +export const generateMetadata = () => getSettingsPageMetadata("common.connect_your_app"); + export default AppConnectionPage; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/(setup)/user-actions/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/(setup)/user-actions/page.tsx index 5199bcf1ff0b..9eae8e157a89 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/(setup)/user-actions/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/(setup)/user-actions/page.tsx @@ -1 +1,5 @@ +import { getSettingsPageMetadata } from "@/modules/settings/lib/metadata"; + +export const generateMetadata = () => getSettingsPageMetadata("common.user_actions"); + export { UserActionsPage as default } from "@/modules/workspaces/settings/(setup)/user-actions/page"; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/general/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/general/page.tsx index a2b3c6d83ab7..5a720f3dfc7a 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/general/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/general/page.tsx @@ -1,3 +1,6 @@ +import { getSettingsPageMetadata } from "@/modules/settings/lib/metadata"; import { GeneralSettingsPage } from "@/modules/workspaces/settings/general/page"; +export const generateMetadata = () => getSettingsPageMetadata("common.workspace_settings"); + export default GeneralSettingsPage; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/page.tsx index 4cdd4cf92191..6fc45023be20 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/page.tsx @@ -10,11 +10,15 @@ import { redactIntegrationCredentials } from "@/lib/integration/redact-credentia import { getIntegrations } from "@/lib/integration/service"; import { getUserLocale } from "@/lib/user/service"; import { getTranslate } from "@/lingodotdev/server"; +import { getSettingsPageMetadata } from "@/modules/settings/lib/metadata"; import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils"; +export const generateMetadata = () => + getSettingsPageMetadata("workspace.integrations.airtable.airtable_integration"); + const Page = async (props: { params: Promise<{ workspaceId: string }> }) => { const params = await props.params; const t = await getTranslate(); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/page.tsx index b8ac95bb59cf..ccf82b26c904 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/page.tsx @@ -13,11 +13,15 @@ import { redactIntegrationCredentials } from "@/lib/integration/redact-credentia import { getIntegrations } from "@/lib/integration/service"; import { getUserLocale } from "@/lib/user/service"; import { getTranslate } from "@/lingodotdev/server"; +import { getSettingsPageMetadata } from "@/modules/settings/lib/metadata"; import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils"; +export const generateMetadata = () => + getSettingsPageMetadata("workspace.integrations.google_sheets.google_sheets_integration"); + const Page = async (props: { params: Promise<{ workspaceId: string }> }) => { const params = await props.params; const t = await getTranslate(); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/page.tsx index 9a7c2e769bbc..ffd16c7bd052 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/page.tsx @@ -16,11 +16,15 @@ import { getNotionDatabases } from "@/lib/notion/service"; import { getUserLocale } from "@/lib/user/service"; import { getTranslate } from "@/lingodotdev/server"; import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys"; +import { getSettingsPageMetadata } from "@/modules/settings/lib/metadata"; import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils"; +export const generateMetadata = () => + getSettingsPageMetadata("workspace.integrations.notion.notion_integration"); + const Page = async (props: { params: Promise<{ workspaceId: string }> }) => { const params = await props.params; const t = await getTranslate(); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/page.tsx index 10d11ec17d46..966c805c8108 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/page.tsx @@ -17,11 +17,14 @@ import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { getIntegrations } from "@/lib/integration/service"; import { getBillingFallbackPath } from "@/lib/membership/navigation"; import { getTranslate } from "@/lingodotdev/server"; +import { getSettingsPageMetadata } from "@/modules/settings/lib/metadata"; import { Card } from "@/modules/ui/components/integration-card"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils"; +export const generateMetadata = () => getSettingsPageMetadata("common.integrations"); + const getStatusText = (count: number, t: TFunction, type: string) => { if (count === 1) return `1 ${type}`; if (count === 0) return t("common.not_connected"); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/page.tsx index 7aaa7328dc01..7084223dbad7 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/page.tsx @@ -7,11 +7,15 @@ import { redactIntegrationCredentials } from "@/lib/integration/redact-credentia import { getIntegrationByType } from "@/lib/integration/service"; import { getUserLocale } from "@/lib/user/service"; import { getTranslate } from "@/lingodotdev/server"; +import { getSettingsPageMetadata } from "@/modules/settings/lib/metadata"; import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageHeader } from "@/modules/ui/components/page-header"; import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils"; +export const generateMetadata = () => + getSettingsPageMetadata("workspace.integrations.slack.slack_integration"); + const Page = async (props: { params: Promise<{ workspaceId: string }> }) => { const params = await props.params; const isEnabled = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/webhooks/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/webhooks/page.tsx index 5f15b859bf8d..0a3c6137bbd7 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/webhooks/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/webhooks/page.tsx @@ -1,3 +1,6 @@ import { WebhooksPage } from "@/modules/integrations/webhooks/page"; +import { getSettingsPageMetadata } from "@/modules/settings/lib/metadata"; + +export const generateMetadata = () => getSettingsPageMetadata("common.webhooks"); export default WebhooksPage; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/languages/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/languages/page.tsx index 1562b6a82855..1c62652f4d30 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/languages/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/languages/page.tsx @@ -1,3 +1,6 @@ +import { getSettingsPageMetadata } from "@/modules/settings/lib/metadata"; import { LanguagesPage } from "@/modules/workspaces/settings/languages/page"; +export const generateMetadata = () => getSettingsPageMetadata("common.survey_languages"); + export default LanguagesPage; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/layout.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/layout.tsx index 1b9682a88a94..d52f251b5019 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/layout.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/layout.tsx @@ -1,4 +1,3 @@ -import { WorkspaceSettingsLayout, metadata } from "@/modules/workspaces/settings/layout"; +import { WorkspaceSettingsLayout } from "@/modules/workspaces/settings/layout"; -export { metadata }; export default WorkspaceSettingsLayout; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/look/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/look/page.tsx index e834a742ba27..f9af1bd93614 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/look/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/look/page.tsx @@ -1,3 +1,6 @@ +import { getSettingsPageMetadata } from "@/modules/settings/lib/metadata"; import { WorkspaceLookSettingsPage } from "@/modules/workspaces/settings/look/page"; +export const generateMetadata = () => getSettingsPageMetadata("common.appearance"); + export default WorkspaceLookSettingsPage; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/tags/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/tags/page.tsx index 7389cbed19cd..68ab724ec68c 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/tags/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/tags/page.tsx @@ -1,3 +1,6 @@ +import { getSettingsPageMetadata } from "@/modules/settings/lib/metadata"; import { TagsPage } from "@/modules/workspaces/settings/tags/page"; +export const generateMetadata = () => getSettingsPageMetadata("common.tags"); + export default TagsPage; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/teams/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/teams/page.tsx index b1275e7c3bb6..02f838157e8a 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/teams/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/teams/page.tsx @@ -1,3 +1,6 @@ import { WorkspaceTeams } from "@/modules/ee/teams/workspace-teams/page"; +import { getSettingsPageMetadata } from "@/modules/settings/lib/metadata"; + +export const generateMetadata = () => getSettingsPageMetadata("common.team_access"); export default WorkspaceTeams; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplateFragment.ts b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplateFragment.ts index 314072b2173a..4c3c04e8b489 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplateFragment.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplateFragment.ts @@ -1,11 +1,37 @@ -const EMAIL_DOCTYPE_PATTERN = /]*>/i; -const EMAIL_BODY_PATTERN = /]*>([\s\S]*?)<\/body>/i; +import { findClosingTag, findOpeningTag } from "@/lib/utils/html-opening-tag"; + const EMAIL_REACT_SERVER_MARKER_PATTERN = //g; +/** + * The body content, or null when the document has no `…`. + * + * Two scans rather than `]*>([\s\S]*?)<\/body>`. That pattern is quadratic twice over: the + * attribute run rescans from every `` follows, and the lazy content group expands + * to the end of the document once per `` when no `` follows. Capping fixes neither — + * the content group is the whole email and cannot be capped, and capping the attribute run makes an + * over-long tag match a LATER `` instead, extracting the wrong span. + * + * Same result as the regex. It matched the leftmost `` that has a `` after it, and if + * none follows the first opening tag then none follows a later one either, so taking the first + * opening tag and the first close after it picks exactly the same span. + */ +const extractBodyContent = (html: string): string | null => { + const openTag = findOpeningTag(html, "body"); + if (!openTag) return null; + + const contentStart = openTag.index + openTag.length; + const contentEnd = findClosingTag(html, "body", contentStart); + + return contentEnd === -1 ? null : html.slice(contentStart, contentEnd); +}; + export const extractEmailBodyFragment = (html: string): string => { - const htmlWithoutDoctype = html.replace(EMAIL_DOCTYPE_PATTERN, "").trim(); - const bodyMatch = EMAIL_BODY_PATTERN.exec(htmlWithoutDoctype); - const fragment = bodyMatch?.[1].trim() ?? htmlWithoutDoctype; + const doctype = findOpeningTag(html, "!DOCTYPE", { requireWordBoundary: false }); + const htmlWithoutDoctype = ( + doctype ? html.slice(0, doctype.index) + html.slice(doctype.index + doctype.length) : html + ).trim(); + + const fragment = extractBodyContent(htmlWithoutDoctype)?.trim() ?? htmlWithoutDoctype; return fragment.replaceAll(EMAIL_REACT_SERVER_MARKER_PATTERN, "").trim(); }; diff --git a/apps/web/app/(auth)/layout.tsx b/apps/web/app/(auth)/layout.tsx index f3f0071f1c81..8cb7d9d3eb77 100644 --- a/apps/web/app/(auth)/layout.tsx +++ b/apps/web/app/(auth)/layout.tsx @@ -1,12 +1,5 @@ -import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay"; - -const AppLayout = async ({ children }: { children: React.ReactNode }) => { - return ( - <> - - {children} - - ); +const AuthGroupLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => { + return <>{children}; }; -export default AppLayout; +export default AuthGroupLayout; diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 8b166e79e12c..42eafc5783d6 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -96,6 +96,7 @@ checksums: auth/signup/company_email_required: 997bf11a31512cedf4ab8acb7b4f56c3 auth/signup/have_an_account: 6f9c2d441e93cb6df9ef7dc898e80e05 auth/signup/log_in: 9fb886eff8d1d67d8bb79f5c3f78edde + auth/signup/password_requirements: 58d15c4e16b28c51b83977d0c2af17bc auth/signup/password_validation_contain_at_least_1_number: 58fb33a486aa4dadfc05e3242fc7ea0b auth/signup/password_validation_minimum_8_and_maximum_128_characters: ec704690a454c7f01e59856c39206259 auth/signup/password_validation_uppercase_and_lowercase: ae98b485024dbff1022f6048e22443cd @@ -230,6 +231,7 @@ checksums: common/delete: 8bcf303dd10a645b5baacb02b47d72c9 common/delete_what: 718ddfcc1dec7f3e8b67856fba838267 common/description: e17686a22ffad04cc7bb70524ed4478b + common/digit_number_of_total: bd36d550b8797c5a928ede29cb10a0f9 common/disable: 81b754fd7962e0bd9b6ba87f3972e7fc common/disabled: 0889a3dfd914a7ef638611796b17bf72 common/disallow: 01c8ed3ce545ed836d3ccffc562c8a0c @@ -274,6 +276,7 @@ checksums: common/finish: ffa7a10f71182b48fefed7135bee24fa common/finished_at: 05d2fa31cf3b2e2255729ec7898240c2 common/first_name: cf040a5d6a9fd696be400380cc99f54b + common/formbricks_homepage: 95a78e9e1812a97221d1f69015561e68 common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb common/full_name: f45991923345e8322c9ff8cd6b7e2b16 common/gathering_responses: c5914490ed81bd77f13d411739f0c9ef @@ -286,6 +289,7 @@ checksums: common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972 common/hide_column: 23ce94db148f2d8e4a0923defead6cf1 + common/hide_password: dd9813264cfc4a7ae515cd5644943c1b common/html: f750870203043349d570d8f5865ca0f8 common/id: c8886d38aeea2ed5f785aba4fc96784b common/image: 048ba7a239de0fbd883ade8558415830 @@ -455,6 +459,7 @@ checksums: common/settings: 8df6777277469c1fd88cc18dde2f1cc3 common/share_feedback: f3c14bfa149fde4035b6965cb8d3e993 common/show: 16dfe5dc481240cd2819a6394f90df92 + common/show_password: 8696d19a0f02613a86727810355fef47 common/show_response_count: 609e5dc7c074d57e711a728fa2f8eb79 common/shown: 63e4ffb245c05e04b636446c3dbdd8df common/size: 227fadeeff951e041ff42031a11a4626 diff --git a/apps/web/lib/utils/client-ip.ts b/apps/web/lib/utils/client-ip.ts index 7e285fcbc9f5..fbd451188baf 100644 --- a/apps/web/lib/utils/client-ip.ts +++ b/apps/web/lib/utils/client-ip.ts @@ -19,7 +19,7 @@ export const UNTRUSTED_CLIENT_IP = "untrusted-client-ip"; const CLIENT_IP_WARNING_INTERVAL_MS = 10 * 60 * 1000; const VALID_DECIMAL_PORT = /^[1-9]\d{0,4}$/; -const BRACKETED_ADDRESS = /^\[([^\[\]]+)\](?::([^:]+))?$/; +const BRACKETED_ADDRESS = /^\[([^[\]]+)\](?::([^:]+))?$/; const IPV4_SOCKET = /^([^:]+):(\d+)$/; type ClientIpWarningReason = "disabled" | "invalid-selected-hop" | "missing-chain" | "short-chain"; diff --git a/apps/web/lib/utils/html-opening-tag.test.ts b/apps/web/lib/utils/html-opening-tag.test.ts new file mode 100644 index 000000000000..c8ed98f6afe5 --- /dev/null +++ b/apps/web/lib/utils/html-opening-tag.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "vitest"; +import { findClosingTag, findOpeningTag, replaceOpeningTags } from "./html-opening-tag"; + +// The regexes this module replaces, kept here as the oracle every case is compared against. +const asRegex = (name: string, wordBoundary = true) => + new RegExp(`<${name}${wordBoundary ? String.raw`\b` : ""}([^>]*)>`, "gi"); + +const CASES: { name: string; wordBoundary?: boolean }[] = [ + { name: "p" }, + { name: "li" }, + { name: "body" }, + { name: "!DOCTYPE", wordBoundary: false }, +]; + +const FIXED = [ + "", + "

a

", + '

a

', + "

spaced

upper

", + "
not a p
", + "

nested

", + "x", + "", + "multiline", + "

", + "text with no tags at all", + "

", + "", + // The shapes a length cap got wrong: an over-long run containing another opening tag. + `

tail`, + `

  • tail`, + `inner`, + `rest`, + // U+0130 lowercases to two code units; a toLowerCase()-based scan would misalign here. + "İ

    after a length-changing character

    ", +]; + +const ALPHABET = "<>/plibodyPLIBODY!DCTYPE \n\t\"'=-0_"; +const random = Array.from({ length: 20000 }, () => { + const n = Math.floor(Math.random() * 40); + let s = ""; + for (let i = 0; i < n; i++) s += ALPHABET[Math.floor(Math.random() * ALPHABET.length)]; + return s; +}); +const CORPUS = [...FIXED, ...random]; + +describe("html opening tag scanner", () => { + test.each(CASES)("replaceOpeningTags matches the regex it replaces ($name)", ({ name, wordBoundary }) => { + for (const source of CORPUS) { + const viaRegex = source.replace(asRegex(name, wordBoundary), (_m, attrs: string) => `[${attrs}]`); + const viaScan = replaceOpeningTags(source, name, (attrs) => `[${attrs}]`, { + requireWordBoundary: wordBoundary, + }); + + expect(viaScan, `input: ${JSON.stringify(source.slice(0, 60))}`).toBe(viaRegex); + } + }); + + test.each(CASES)( + "findOpeningTag reports the regex's index and capture ($name)", + ({ name, wordBoundary }) => { + for (const source of CORPUS) { + const expected = asRegex(name, wordBoundary).exec(source); + const actual = findOpeningTag(source, name, { requireWordBoundary: wordBoundary }); + + if (expected === null) { + expect(actual, `input: ${JSON.stringify(source.slice(0, 60))}`).toBeNull(); + continue; + } + expect(actual, `input: ${JSON.stringify(source.slice(0, 60))}`).toEqual({ + index: expected.index, + length: expected[0].length, + attributes: expected[1], + }); + } + } + ); + + test("findClosingTag matches the case-insensitive close the regex used", () => { + const iDot = String.fromCharCode(0x130); // lowercases to two code units + const cases = [ + ["", 0], + ["x", 7], + ["xrest", 7], + ["no close here", -1], + // A toLowerCase()-based search would report an index into a longer string here. + [`prefix${iDot}suffix`, `prefix${iDot}suffix`.length], + [`${iDot.repeat(20)}`, 20], + ] as const; + + for (const [source, expected] of cases) { + expect(findClosingTag(source, "body"), JSON.stringify(source)).toBe(expected); + } + }); + + test("stays linear where the regex was quadratic", () => { + // `

    ` anywhere: the regex rescans to the end from every occurrence. + const pathological = "

    `[${attrs}]`); + const elapsedMs = performance.now() - startedAt; + + expect(result).toBe(pathological); + expect(elapsedMs).toBeLessThan(500); + }); +}); diff --git a/apps/web/lib/utils/html-opening-tag.ts b/apps/web/lib/utils/html-opening-tag.ts new file mode 100644 index 000000000000..50867d47fdf6 --- /dev/null +++ b/apps/web/lib/utils/html-opening-tag.ts @@ -0,0 +1,137 @@ +/** + * Locate HTML opening tags the way `/]*>/gi` did, without the regex. + * + * Why not the regex: `[^>]*` is unbounded, so on a run of `` after it the engine + * rescans to the end of the document from every occurrence — O(N^2), measured 7.4s on 200k + * characters. Capping the run is not a fix either: when the over-long run itself contains another + * `]*` cannot cross a `>`, so a tag always ends at the first + * `>` after its name — one `indexOf` — and if no `>` follows the first opening tag then none follows + * a later one either, so the scan stops rather than retrying. Same matches, one pass, no cap. + */ + +/** `\b` after an ASCII-letter tag name: the next character must exist and not be a word character. */ +const isWordCharacterCode = (code: number | undefined): boolean => + code !== undefined && + ((code >= 48 && code <= 57) || // 0-9 + (code >= 65 && code <= 90) || // A-Z + (code >= 97 && code <= 122) || // a-z + code === 95); // _ + +/** + * Past the end of the string `codePointAt` yields `undefined`, which never equals another position's + * code — the same outcome `charCodeAt`'s `NaN` produced, so the scans below are unchanged. + */ +const toAsciiLowerCode = (code: number | undefined): number | undefined => + code !== undefined && code >= 65 && code <= 90 ? code + 32 : code; + +/** + * ASCII-case-insensitive `indexOf`, matching the `i` flag's behaviour on the ASCII literals used + * here. Deliberately not `haystack.toLowerCase().indexOf(...)`: lowercasing is not length-preserving + * (U+0130 becomes two code units), so a document containing one would misalign every later index. + */ +const indexOfAsciiCaseInsensitive = (haystack: string, needle: string, from: number): number => { + const lastStart = haystack.length - needle.length; + for (let start = Math.max(0, from); start <= lastStart; start++) { + let offset = 0; + while ( + offset < needle.length && + toAsciiLowerCode(haystack.codePointAt(start + offset)) === toAsciiLowerCode(needle.codePointAt(offset)) + ) { + offset++; + } + if (offset === needle.length) return start; + } + return -1; +}; + +export interface OpeningTagMatch { + /** Index of the `<`. */ + readonly index: number; + /** Length of the whole tag, `<` through `>`. */ + readonly length: number; + /** Everything between the tag name and the `>` — the regex's capture group. */ + readonly attributes: string; +} + +/** + * The first `` at or after `from`, or null when there is none. + * + * `requireWordBoundary` mirrors whether the pattern had `\b` after the name: `]*>` has no such constraint and matches ``. + */ +export const findOpeningTag = ( + source: string, + name: string, + { requireWordBoundary = true }: { requireWordBoundary?: boolean } = {}, + from = 0 +): OpeningTagMatch | null => { + const prefix = `<${name}`; + let searchFrom = from; + + while (searchFrom <= source.length) { + const start = indexOfAsciiCaseInsensitive(source, prefix, searchFrom); + if (start === -1) return null; + + const afterPrefix = start + prefix.length; + // `\b` fails when the next character continues the word, e.g. ``. The regex + // would then retry one position later, so the scan does too. + if (requireWordBoundary && isWordCharacterCode(source.codePointAt(afterPrefix))) { + searchFrom = start + 1; + continue; + } + + const close = source.indexOf(">", afterPrefix); + // `[^>]*` cannot cross a `>`, so a tag ends at the first one. No `>` after this opening tag + // means none after any later one either — the regex would scan the rest of the document to + // discover that; there is nothing left to find. + if (close === -1) return null; + + return { + index: start, + length: close - start + 1, + attributes: source.slice(afterPrefix, close), + }; + } + + return null; +}; + +/** + * Index of the first `` at or after `from`, or -1. The counterpart to `findOpeningTag`, and + * case-insensitive the same way. + */ +export const findClosingTag = (source: string, name: string, from = 0): number => + indexOfAsciiCaseInsensitive(source, ``, from); + +/** + * Replace every `` with `replace(attributes, tag)`, matching `/]*)>/gi` under + * `replaceAll`. Like the regex, scanning resumes after the replaced tag's `>`, so a replacement + * that itself contains a tag is never rescanned. + * + * `tag` is the matched text exactly as it appeared, which callers that pass a tag through unchanged + * need: rebuilding it from `name` would normalize `

  • ` to `
  • `. + */ +export const replaceOpeningTags = ( + source: string, + name: string, + replace: (attributes: string, tag: string) => string, + options: { requireWordBoundary?: boolean } = {} +): string => { + let result = ""; + let copiedTo = 0; + + for ( + let match = findOpeningTag(source, name, options, 0); + match !== null; + match = findOpeningTag(source, name, options, copiedTo) + ) { + const tag = source.slice(match.index, match.index + match.length); + result += source.slice(copiedTo, match.index) + replace(match.attributes, tag); + copiedTo = match.index + match.length; + } + + return copiedTo === 0 ? source : result + source.slice(copiedTo); +}; diff --git a/apps/web/lib/utils/recall.ts b/apps/web/lib/utils/recall.ts index 4de36c2f8e4d..452b002296a3 100644 --- a/apps/web/lib/utils/recall.ts +++ b/apps/web/lib/utils/recall.ts @@ -33,10 +33,19 @@ export const extractIds = (text: string): string[] => { }; // Extracts the fallback value from a string containing the "fallback" pattern. +// An index scan, not `/fallback:([^#]*)#/`: that pattern is O(N^2) on a long run of `fallback:` +// with no `#` after it, because the engine rescans to the end from every occurrence. Identical +// result — `[^#]*` cannot cross a `#`, so the regex ends at the first `#` after the FIRST +// `fallback:`, and if none follows that one none follows a later one either. +const FALLBACK_MARKER = "fallback:"; + export const extractFallbackValue = (text: string): string => { - const pattern = /fallback:([^#]*)#/; - const match = text.match(pattern); - return match?.[1] ?? ""; + const markerStart = text.indexOf(FALLBACK_MARKER); + if (markerStart === -1) return ""; + + const valueStart = markerStart + FALLBACK_MARKER.length; + const valueEnd = text.indexOf("#", valueStart); + return valueEnd === -1 ? "" : text.slice(valueStart, valueEnd); }; // Extracts the complete recall information (ID and fallback) from a headline string. diff --git a/apps/web/lib/utils/video-upload.test.ts b/apps/web/lib/utils/video-upload.test.ts index 62acc1e265a8..60aff235370b 100644 --- a/apps/web/lib/utils/video-upload.test.ts +++ b/apps/web/lib/utils/video-upload.test.ts @@ -137,3 +137,37 @@ describe("extractLoomId", () => { expect(extractLoomId("https://loom.com/invalid/abcdef123456")).toBeNull(); }); }); + +describe("extractYoutubeId — stored-value denial of service (ENG-2789)", () => { + // The pattern list this replaced used `youtube\\.com.*v=(…)`, whose `.*` backtracks once per + // `youtube.com`. `ZStorageUrl` is an unbounded `z.string()`, so a value this long persists and + // reaches the RESPONDENT renderer, where `element-media.tsx` converts it twice per render. + test("a long repeated-host URL resolves fast instead of blocking the thread", () => { + const stored = `https://youtube.com/${"youtube.com/".repeat(25_600)}`; // 307,220 characters + + const startedAt = performance.now(); + const result = extractYoutubeId(stored); + const elapsedMs = performance.now() - startedAt; + + expect(result).toBeNull(); + // Budget chosen from measurements, not a round number: the scan costs 6ms uninstrumented but + // ~330ms under the coverage run CI uses, which slows tight character loops far more than it + // slows a native regex. The pattern this replaced takes ~3300ms on the same input either way, + // so 2000ms sits above the instrumented pass and below the regression it guards against. + expect(elapsedMs).toBeLessThan(2000); + }); + + // Greedy `.*` took the LAST marker on the line and backtracked to an earlier one when the last + // had no id after it. Both are load-bearing, so they are pinned rather than left to the corpus. + test.each([ + ["https://www.youtube.com/watch?x=v=FIRST&v=SECOND", "SECOND"], + ["https://youtube.com/embed/abc/embed/def", "def"], + ["https://youtube.com/watch?v=&v=OK", "OK"], + ["https://youtube.com/watch?v=&v=", null], + // `.` cannot cross a line terminator, so a marker on the next line is not reachable. + ["youtube.com\nv=NEXTLINE", null], + ["youtube.com v=SAMELINE\nyoutube.com v=SECOND", "SAMELINE"], + ])("resolves %s to %s, as the pattern did", (url, expected) => { + expect(extractYoutubeId(url)).toBe(expected); + }); +}); diff --git a/apps/web/lib/utils/video-upload.ts b/apps/web/lib/utils/video-upload.ts index e921679bb9c3..990f37865d64 100644 --- a/apps/web/lib/utils/video-upload.ts +++ b/apps/web/lib/utils/video-upload.ts @@ -1,3 +1,7 @@ +import { extractYoutubeId } from "@formbricks/survey-ui/youtube-id"; + +export { extractYoutubeId }; + export const checkForYoutubeUrl = (url: string): boolean => { try { const youtubeUrl = new URL(url); @@ -50,29 +54,6 @@ export const checkForLoomUrl = (url: string): boolean => { } }; -export const extractYoutubeId = (url: string): string | null => { - let id = ""; - - // Regular expressions for various YouTube URL formats - const regExpList = [ - /youtu\.be\/([a-zA-Z0-9_-]+)/, // youtu.be/ - /youtube\.com.*v=([a-zA-Z0-9_-]+)/, // youtube.com/watch?v= - /youtube\.com.*embed\/([a-zA-Z0-9_-]+)/, // youtube.com/embed/ - /youtube-nocookie\.com\/embed\/([a-zA-Z0-9_-]+)/, // youtube-nocookie.com/embed/ - ]; - - regExpList.some((regExp) => { - const match = regExp.exec(url); - if (match?.[1]) { - id = match[1]; - return true; - } - return false; - }); - - return id || null; -}; - export const extractVimeoId = (url: string): string | null => { const regExp = /vimeo\.com\/(?:video\/)?(\d+)/; const match = regExp.exec(url); diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 921c4945c191..7617a271bbc5 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -114,6 +114,7 @@ "company_email_required": "Bitte registriere dich mit deiner geschäftlichen E-Mail-Adresse.", "have_an_account": "Hast du bereits ein Konto?", "log_in": "Anmelden", + "password_requirements": "Passwortanforderungen", "password_validation_contain_at_least_1_number": "Mindestens 1 Zahl enthalten", "password_validation_minimum_8_and_maximum_128_characters": "Mindestens 8 & maximal 128 Zeichen", "password_validation_uppercase_and_lowercase": "Mischung aus Groß- und Kleinbuchstaben", @@ -259,6 +260,7 @@ "delete": "Löschen", "delete_what": "{deleteWhat} löschen", "description": "Beschreibung", + "digit_number_of_total": "Ziffer {number} von {total}", "disable": "Deaktivieren", "disabled": "Deaktiviert", "disallow": "Nicht erlauben", @@ -303,6 +305,7 @@ "finish": "Fertig", "finished_at": "Beendet um", "first_name": "Vorname", + "formbricks_homepage": "Formbricks-Startseite", "formbricks_version": "Formbricks-Version", "full_name": "Vollständiger Name", "gathering_responses": "Sammle Antworten", @@ -315,6 +318,7 @@ "hidden_field": "Verstecktes Feld", "hidden_fields": "Versteckte Felder", "hide_column": "Spalte ausblenden", + "hide_password": "Passwort verbergen", "html": "HTML", "id": "ID", "image": "Bild", @@ -484,6 +488,7 @@ "settings": "Einstellungen", "share_feedback": "Feedback teilen", "show": "Anzeigen", + "show_password": "Passwort anzeigen", "show_response_count": "Antwortanzahl anzeigen", "shown": "Angezeigt", "size": "Größe", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index ce4f8afa4274..d244bd37ad7d 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -114,6 +114,7 @@ "company_email_required": "Please sign up with your company email address.", "have_an_account": "Have an account?", "log_in": "Log in", + "password_requirements": "Password requirements", "password_validation_contain_at_least_1_number": "Contain at least 1 number", "password_validation_minimum_8_and_maximum_128_characters": "Minimum 8 & Maximum 128 characters", "password_validation_uppercase_and_lowercase": "Mix of uppercase and lowercase", @@ -259,6 +260,7 @@ "delete": "Delete", "delete_what": "Delete {deleteWhat}", "description": "Description", + "digit_number_of_total": "Digit {number} of {total}", "disable": "Disable", "disabled": "Disabled", "disallow": "Do not allow", @@ -303,6 +305,7 @@ "finish": "Finish", "finished_at": "Finished At", "first_name": "First Name", + "formbricks_homepage": "Formbricks homepage", "formbricks_version": "Formbricks Version", "full_name": "Full name", "gathering_responses": "Gathering responses", @@ -315,6 +318,7 @@ "hidden_field": "Hidden field", "hidden_fields": "Hidden fields", "hide_column": "Hide column", + "hide_password": "Hide password", "html": "HTML", "id": "ID", "image": "Image", @@ -484,6 +488,7 @@ "settings": "Settings", "share_feedback": "Share feedback", "show": "Show", + "show_password": "Show password", "show_response_count": "Show response count", "shown": "Shown", "size": "Size", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 31f7b6da62b3..38462e187aa0 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -114,6 +114,7 @@ "company_email_required": "Regístrate con el correo electrónico de tu empresa.", "have_an_account": "¿Tienes una cuenta?", "log_in": "Iniciar sesión", + "password_requirements": "Requisitos de contraseña", "password_validation_contain_at_least_1_number": "Contener al menos 1 número", "password_validation_minimum_8_and_maximum_128_characters": "Mínimo 8 y máximo 128 caracteres", "password_validation_uppercase_and_lowercase": "Mezcla de mayúsculas y minúsculas", @@ -259,6 +260,7 @@ "delete": "Eliminar", "delete_what": "Eliminar {deleteWhat}", "description": "Descripción", + "digit_number_of_total": "Dígito {number} de {total}", "disable": "Desactivar", "disabled": "Desactivado", "disallow": "No permitir", @@ -303,6 +305,7 @@ "finish": "Finalizar", "finished_at": "Finalizado el", "first_name": "Nombre", + "formbricks_homepage": "Página de inicio de Formbricks", "formbricks_version": "Versión de Formbricks", "full_name": "Nombre completo", "gathering_responses": "Recopilando respuestas", @@ -315,6 +318,7 @@ "hidden_field": "Campo oculto", "hidden_fields": "Campos ocultos", "hide_column": "Ocultar columna", + "hide_password": "Ocultar contraseña", "html": "HTML", "id": "ID", "image": "Imagen", @@ -484,6 +488,7 @@ "settings": "Ajustes", "share_feedback": "Compartir comentarios", "show": "Mostrar", + "show_password": "Mostrar contraseña", "show_response_count": "Mostrar recuento de respuestas", "shown": "Mostrado", "size": "Tamaño", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 06a82a1983d2..2026d940a5ef 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -114,6 +114,7 @@ "company_email_required": "Veuillez vous inscrire avec votre adresse e-mail professionnelle.", "have_an_account": "Avez-vous un compte ?", "log_in": "Se connecter", + "password_requirements": "Exigences du mot de passe", "password_validation_contain_at_least_1_number": "Contenir au moins 1 chiffre", "password_validation_minimum_8_and_maximum_128_characters": "Minimum 8 et Maximum 128 caractères", "password_validation_uppercase_and_lowercase": "Mélange de majuscules et de minuscules", @@ -259,6 +260,7 @@ "delete": "Supprimer", "delete_what": "Supprimer {deleteWhat}", "description": "Description", + "digit_number_of_total": "Chiffre {number} sur {total}", "disable": "Désactiver", "disabled": "Désactivé", "disallow": "Ne pas autoriser", @@ -303,6 +305,7 @@ "finish": "Terminer", "finished_at": "Terminé le", "first_name": "Prénom", + "formbricks_homepage": "Page d'accueil de Formbricks", "formbricks_version": "Version de Formbricks", "full_name": "Nom complet", "gathering_responses": "Collecte des réponses", @@ -315,6 +318,7 @@ "hidden_field": "Champ caché", "hidden_fields": "Champs cachés", "hide_column": "Cacher la colonne", + "hide_password": "Masquer le mot de passe", "html": "HTML", "id": "ID", "image": "Image", @@ -484,6 +488,7 @@ "settings": "Paramètres", "share_feedback": "Partager des commentaires", "show": "Montrer", + "show_password": "Afficher le mot de passe", "show_response_count": "Afficher le nombre de réponses", "shown": "Montré", "size": "Taille", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index 12081d9c11da..9bdb32ecd47e 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -114,6 +114,7 @@ "company_email_required": "Kérjük, regisztrálj a céges e-mail-címeddel.", "have_an_account": "Van fiókja?", "log_in": "Bejelentkezés", + "password_requirements": "Jelszókövetelmények", "password_validation_contain_at_least_1_number": "Legalább 1 számot tartalmazzon", "password_validation_minimum_8_and_maximum_128_characters": "Legalább 8 és legfeljebb 128 karakter", "password_validation_uppercase_and_lowercase": "Nagybetűk és kisbetűk vegyesen", @@ -259,6 +260,7 @@ "delete": "Törlés", "delete_what": "{deleteWhat} törlése", "description": "Leírás", + "digit_number_of_total": "{number}. számjegy a {total}-ból", "disable": "Letiltás", "disabled": "Letiltva", "disallow": "Ne engedélyezze", @@ -303,6 +305,7 @@ "finish": "Befejezés", "finished_at": "Befejezve", "first_name": "Keresztnév", + "formbricks_homepage": "Formbricks kezdőlap", "formbricks_version": "Formbricks verziója", "full_name": "Teljes név", "gathering_responses": "Válaszok összegyűjtése", @@ -315,6 +318,7 @@ "hidden_field": "Rejtett mező", "hidden_fields": "Rejtett mezők", "hide_column": "Oszlop elrejtése", + "hide_password": "Jelszó elrejtése", "html": "HTML", "id": "Azonosító", "image": "Kép", @@ -484,6 +488,7 @@ "settings": "Beállítások", "share_feedback": "Visszajelzés megosztása", "show": "Megjelenítés", + "show_password": "Jelszó megjelenítése", "show_response_count": "Válaszok számának megjelenítése", "shown": "Megjelenítve", "size": "Méret", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 4844bb471ec9..2e4dc07424de 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -114,6 +114,7 @@ "company_email_required": "会社のメールアドレスで登録してください。", "have_an_account": "すでにアカウントをお持ちですか?", "log_in": "ログイン", + "password_requirements": "パスワードの要件", "password_validation_contain_at_least_1_number": "1つ以上の数字を含める", "password_validation_minimum_8_and_maximum_128_characters": "8文字以上128文字以下", "password_validation_uppercase_and_lowercase": "大文字と小文字を混ぜる", @@ -259,6 +260,7 @@ "delete": "削除", "delete_what": "{deleteWhat}を削除", "description": "説明", + "digit_number_of_total": "{total}桁中{number}桁目", "disable": "無効にする", "disabled": "無効", "disallow": "許可しない", @@ -303,6 +305,7 @@ "finish": "完了", "finished_at": "完了日時", "first_name": "名", + "formbricks_homepage": "Formbricksホームページ", "formbricks_version": "Formbricksバージョン", "full_name": "氏名", "gathering_responses": "回答を収集しています", @@ -315,6 +318,7 @@ "hidden_field": "非表示フィールド", "hidden_fields": "非表示フィールド", "hide_column": "列を非表示", + "hide_password": "パスワードを非表示", "html": "HTML", "id": "ID", "image": "画像", @@ -484,6 +488,7 @@ "settings": "設定", "share_feedback": "フィードバックを共有", "show": "表示", + "show_password": "パスワードを表示", "show_response_count": "回答数を表示", "shown": "表示済み", "size": "サイズ", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 6bb8c782e2ea..6bc904b56445 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -114,6 +114,7 @@ "company_email_required": "Registreer je met je zakelijke e-mailadres.", "have_an_account": "Heeft u een account?", "log_in": "Inloggen", + "password_requirements": "Wachtwoordvereisten", "password_validation_contain_at_least_1_number": "Bevat minimaal 1 nummer", "password_validation_minimum_8_and_maximum_128_characters": "Minimaal 8 en maximaal 128 tekens", "password_validation_uppercase_and_lowercase": "Mix van hoofdletters en kleine letters", @@ -259,6 +260,7 @@ "delete": "Verwijderen", "delete_what": "Verwijder {deleteWhat}", "description": "Beschrijving", + "digit_number_of_total": "Cijfer {number} van {total}", "disable": "Uitzetten", "disabled": "Uitgeschakeld", "disallow": "Niet toestaan", @@ -303,6 +305,7 @@ "finish": "Finish", "finished_at": "Voltooid op", "first_name": "Voornaam", + "formbricks_homepage": "Formbricks startpagina", "formbricks_version": "Formbricks-versie", "full_name": "Volledige naam", "gathering_responses": "Reacties verzamelen", @@ -315,6 +318,7 @@ "hidden_field": "Verborgen veld", "hidden_fields": "Verborgen velden", "hide_column": "Kolom verbergen", + "hide_password": "Wachtwoord verbergen", "html": "HTML", "id": "ID", "image": "Afbeelding", @@ -484,6 +488,7 @@ "settings": "Instellingen", "share_feedback": "Deel feedback", "show": "Show", + "show_password": "Wachtwoord tonen", "show_response_count": "Toon het aantal reacties", "shown": "Getoond", "size": "Maat", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 10912feea88e..d90ed2cd7670 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -114,6 +114,7 @@ "company_email_required": "Cadastre-se com o e-mail da sua empresa.", "have_an_account": "Já tem uma conta?", "log_in": "Fazer login", + "password_requirements": "Requisitos da senha", "password_validation_contain_at_least_1_number": "Conter pelo menos 1 número", "password_validation_minimum_8_and_maximum_128_characters": "Mínimo 8 e Máximo 128 caracteres", "password_validation_uppercase_and_lowercase": "mistura de maiúsculas e minúsculas", @@ -259,6 +260,7 @@ "delete": "Apagar", "delete_what": "Excluir {deleteWhat}", "description": "Descrição", + "digit_number_of_total": "Dígito {number} de {total}", "disable": "desativar", "disabled": "Desativado", "disallow": "Não permita", @@ -303,6 +305,7 @@ "finish": "Terminar", "finished_at": "Finalizado em", "first_name": "Primeiro nome", + "formbricks_homepage": "Página inicial do Formbricks", "formbricks_version": "Versão do Formbricks", "full_name": "Nome completo", "gathering_responses": "Recolhendo respostas", @@ -315,6 +318,7 @@ "hidden_field": "Campo oculto", "hidden_fields": "Campos ocultos", "hide_column": "Ocultar coluna", + "hide_password": "Ocultar senha", "html": "HTML", "id": "ID", "image": "imagem", @@ -484,6 +488,7 @@ "settings": "Configurações", "share_feedback": "Compartilhar feedback", "show": "Legal", + "show_password": "Mostrar senha", "show_response_count": "Mostrar contagem de respostas", "shown": "mostrado", "size": "Tamanho", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 6b4ff2ee9186..735da485e906 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -114,6 +114,7 @@ "company_email_required": "Registe-se com o e-mail da sua empresa.", "have_an_account": "Tem uma conta?", "log_in": "Iniciar sessão", + "password_requirements": "Requisitos da palavra-passe", "password_validation_contain_at_least_1_number": "Conter pelo menos 1 número", "password_validation_minimum_8_and_maximum_128_characters": "Mínimo 8 e Máximo 128 caracteres", "password_validation_uppercase_and_lowercase": "Mistura de maiúsculas e minúsculas", @@ -259,6 +260,7 @@ "delete": "Eliminar", "delete_what": "Eliminar {deleteWhat}", "description": "Descrição", + "digit_number_of_total": "Dígito {number} de {total}", "disable": "Desativar", "disabled": "Desativado", "disallow": "Não permitir", @@ -303,6 +305,7 @@ "finish": "Concluir", "finished_at": "Concluído Em", "first_name": "Primeiro nome", + "formbricks_homepage": "Página inicial da Formbricks", "formbricks_version": "Versão do Formbricks", "full_name": "Nome completo", "gathering_responses": "A recolher respostas", @@ -315,6 +318,7 @@ "hidden_field": "Campo oculto", "hidden_fields": "Campos ocultos", "hide_column": "Ocultar coluna", + "hide_password": "Ocultar palavra-passe", "html": "HTML", "id": "ID", "image": "Imagem", @@ -484,6 +488,7 @@ "settings": "Configurações", "share_feedback": "Partilhar feedback", "show": "Mostrar", + "show_password": "Mostrar palavra-passe", "show_response_count": "Mostrar contagem de respostas", "shown": "Mostrado", "size": "Tamanho", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 390ada0ae948..b5be058a9693 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -114,6 +114,7 @@ "company_email_required": "Înregistrează-te cu adresa de e-mail a companiei.", "have_an_account": "Ai un cont?", "log_in": "Conectează-te", + "password_requirements": "Cerințe parolă", "password_validation_contain_at_least_1_number": "Conține cel puțin 1 număr", "password_validation_minimum_8_and_maximum_128_characters": "Minim 8 & Maxim 128 caractere", "password_validation_uppercase_and_lowercase": "Amestec de majuscule și minuscule", @@ -259,6 +260,7 @@ "delete": "Șterge", "delete_what": "Șterge {deleteWhat}", "description": "Descriere", + "digit_number_of_total": "Cifra {number} din {total}", "disable": "Dezactivează", "disabled": "Dezactivat", "disallow": "Nu permite", @@ -303,6 +305,7 @@ "finish": "Finalizează", "finished_at": "Terminat la", "first_name": "Prenume", + "formbricks_homepage": "Pagina principală Formbricks", "formbricks_version": "Versiunea Formbricks", "full_name": "Nume complet", "gathering_responses": "Culegere răspunsuri", @@ -315,6 +318,7 @@ "hidden_field": "Câmp ascuns", "hidden_fields": "Câmpuri ascunse", "hide_column": "Ascunde coloana", + "hide_password": "Ascunde parola", "html": "HTML", "id": "ID", "image": "Imagine", @@ -484,6 +488,7 @@ "settings": "Setări", "share_feedback": "Împărtășește feedback", "show": "Afișează", + "show_password": "Afișează parola", "show_response_count": "Afișează numărul de răspunsuri", "shown": "Afișat", "size": "Mărime", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 1a7756837918..01ba865c3a42 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -114,6 +114,7 @@ "company_email_required": "Зарегистрируйтесь, используя корпоративный адрес электронной почты.", "have_an_account": "Уже есть аккаунт?", "log_in": "Войти", + "password_requirements": "Требования к паролю", "password_validation_contain_at_least_1_number": "Содержит как минимум 1 цифру", "password_validation_minimum_8_and_maximum_128_characters": "От 8 до 128 символов", "password_validation_uppercase_and_lowercase": "Сочетание заглавных и строчных букв", @@ -259,6 +260,7 @@ "delete": "Удалить", "delete_what": "Удалить {deleteWhat}", "description": "Описание", + "digit_number_of_total": "Цифра {number} из {total}", "disable": "Отключить", "disabled": "Отключено", "disallow": "Не разрешать", @@ -303,6 +305,7 @@ "finish": "Завершить", "finished_at": "Завершено", "first_name": "Имя", + "formbricks_homepage": "Домашняя страница Formbricks", "formbricks_version": "Версия Formbricks", "full_name": "Полное имя", "gathering_responses": "Сбор ответов", @@ -315,6 +318,7 @@ "hidden_field": "Скрытое поле", "hidden_fields": "Скрытые поля", "hide_column": "Скрыть столбец", + "hide_password": "Скрыть пароль", "html": "HTML", "id": "ID", "image": "Изображение", @@ -484,6 +488,7 @@ "settings": "Настройки", "share_feedback": "Поделиться отзывом", "show": "Показать", + "show_password": "Показать пароль", "show_response_count": "Показать количество ответов", "shown": "Показано", "size": "Размер", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 636ac3b518f6..caaac8b756e4 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -114,6 +114,7 @@ "company_email_required": "Registrera dig med din företagsmejladress.", "have_an_account": "Har du ett konto?", "log_in": "Logga in", + "password_requirements": "Lösenordskrav", "password_validation_contain_at_least_1_number": "Innehålla minst 1 siffra", "password_validation_minimum_8_and_maximum_128_characters": "Minst 8 och högst 128 tecken", "password_validation_uppercase_and_lowercase": "Blandning av stora och små bokstäver", @@ -259,6 +260,7 @@ "delete": "Ta bort", "delete_what": "Ta bort {deleteWhat}", "description": "Beskrivning", + "digit_number_of_total": "Siffra {number} av {total}", "disable": "Inaktivera", "disabled": "Inaktiverad", "disallow": "Tillåt inte", @@ -303,6 +305,7 @@ "finish": "Slutför", "finished_at": "Avslutad", "first_name": "Förnamn", + "formbricks_homepage": "Formbricks startsida", "formbricks_version": "Formbricks-version", "full_name": "Fullständigt namn", "gathering_responses": "Samlar in svar", @@ -315,6 +318,7 @@ "hidden_field": "Dolt fält", "hidden_fields": "Dolda fält", "hide_column": "Dölj kolumn", + "hide_password": "Dölj lösenord", "html": "HTML", "id": "ID", "image": "Bild", @@ -484,6 +488,7 @@ "settings": "Inställningar", "share_feedback": "Dela feedback", "show": "Visa", + "show_password": "Visa lösenord", "show_response_count": "Visa antal svar", "shown": "Visad", "size": "Storlek", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index 065f1cdf2432..d0a6dea92fc1 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -114,6 +114,7 @@ "company_email_required": "Lütfen şirket e-posta adresinizle kaydolun.", "have_an_account": "Hesabınız var mı?", "log_in": "Giriş yap", + "password_requirements": "Şifre gereksinimleri", "password_validation_contain_at_least_1_number": "En az 1 rakam içermeli", "password_validation_minimum_8_and_maximum_128_characters": "En az 8, en fazla 128 karakter", "password_validation_uppercase_and_lowercase": "Büyük ve küçük harf karışımı", @@ -259,6 +260,7 @@ "delete": "Sil", "delete_what": "{deleteWhat} sil", "description": "Açıklama", + "digit_number_of_total": "{number}/{total}. basamak", "disable": "Devre dışı bırak", "disabled": "Devre Dışı", "disallow": "İzin verme", @@ -303,6 +305,7 @@ "finish": "Bitir", "finished_at": "Tamamlanma Zamanı", "first_name": "Ad", + "formbricks_homepage": "Formbricks ana sayfası", "formbricks_version": "Formbricks Sürümü", "full_name": "Tam ad", "gathering_responses": "Yanıtlar toplanıyor", @@ -315,6 +318,7 @@ "hidden_field": "Gizli alan", "hidden_fields": "Gizli alanlar", "hide_column": "Sütunu gizle", + "hide_password": "Şifreyi gizle", "html": "HTML", "id": "ID", "image": "Görsel", @@ -484,6 +488,7 @@ "settings": "Ayarlar", "share_feedback": "Geri bildirim paylaş", "show": "Göster", + "show_password": "Şifreyi göster", "show_response_count": "Yanıt sayısını göster", "shown": "Gösterildi", "size": "Boyut", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 50205a75e0d1..a6543fcea067 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -114,6 +114,7 @@ "company_email_required": "请使用您的公司电子邮箱注册。", "have_an_account": "有账户?", "log_in": "登录", + "password_requirements": "密码要求", "password_validation_contain_at_least_1_number": "包含至少 1 个 数字", "password_validation_minimum_8_and_maximum_128_characters": "至少 8 个 和 最多 128 个 字符", "password_validation_uppercase_and_lowercase": "大小写混合", @@ -259,6 +260,7 @@ "delete": "删除", "delete_what": "删除{deleteWhat}", "description": "描述", + "digit_number_of_total": "第 {number} 位,共 {total} 位", "disable": "禁用", "disabled": "已禁用", "disallow": "不允许", @@ -303,6 +305,7 @@ "finish": "完成", "finished_at": "完成时间", "first_name": "名字", + "formbricks_homepage": "Formbricks 主页", "formbricks_version": "Formbricks 版本", "full_name": "全名", "gathering_responses": "收集反馈", @@ -315,6 +318,7 @@ "hidden_field": "隐藏 字段", "hidden_fields": "隐藏 字段", "hide_column": "隐藏 列", + "hide_password": "隐藏密码", "html": "HTML", "id": "ID", "image": "图片", @@ -484,6 +488,7 @@ "settings": "设置", "share_feedback": "分享 反馈", "show": "显示", + "show_password": "显示密码", "show_response_count": "显示 响应 计数", "shown": "显示", "size": "尺寸", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index b09e118fb172..bd49670114ed 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -114,6 +114,7 @@ "company_email_required": "請使用您的公司電子郵件地址註冊。", "have_an_account": "已有帳戶?", "log_in": "登入", + "password_requirements": "密碼要求", "password_validation_contain_at_least_1_number": "包含至少 1 個數字", "password_validation_minimum_8_and_maximum_128_characters": "最少 8 個 & 最多 128 個字元", "password_validation_uppercase_and_lowercase": "混合使用大小寫字母", @@ -259,6 +260,7 @@ "delete": "刪除", "delete_what": "刪除{deleteWhat}", "description": "描述", + "digit_number_of_total": "第 {number} 位數字,共 {total} 位", "disable": "停用", "disabled": "已停用", "disallow": "不允許", @@ -303,6 +305,7 @@ "finish": "完成", "finished_at": "完成時間", "first_name": "名字", + "formbricks_homepage": "Formbricks 首頁", "formbricks_version": "Formbricks 版本", "full_name": "全名", "gathering_responses": "收集回應中", @@ -315,6 +318,7 @@ "hidden_field": "隱藏欄位", "hidden_fields": "隱藏欄位", "hide_column": "隱藏欄位", + "hide_password": "隱藏密碼", "html": "HTML", "id": "ID", "image": "圖片", @@ -484,6 +488,7 @@ "settings": "設定", "share_feedback": "分享回饋", "show": "顯示", + "show_password": "顯示密碼", "show_response_count": "顯示回應數", "shown": "已顯示", "size": "大小", diff --git a/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx b/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx index 98344726160e..69184e9bad44 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx +++ b/apps/web/modules/analysis/components/SingleResponseCard/components/SingleResponseCardBody.tsx @@ -12,7 +12,7 @@ import { getSurveyDateFormatMap } from "@/lib/utils/date-display"; import { parseRecallInfo } from "@/lib/utils/recall"; import { ResponseCardQuotas } from "@/modules/ee/quotas/components/single-response-card-quotas"; import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; -import { isValidValue } from "../util"; +import { isValidValue, splitRecallHighlights } from "../util"; import { ElementSkip } from "./ElementSkip"; import { HiddenFields } from "./HiddenFields"; import { RenderResponse } from "./RenderResponse"; @@ -37,9 +37,7 @@ export const SingleResponseCardBody = ({ const isFirstElementAnswered = elements[0] ? !!response.data[elements[0].id] : false; const { t } = useTranslation(); const formatTextWithSlashes = (text: string) => { - // Updated regex to match content between #/ and \# - const regex = /#\/(.*?)\\#/g; - const parts = text.split(regex); + const parts = splitRecallHighlights(text); return parts.map((part, index) => { // Check if the part was inside #/ and \# diff --git a/apps/web/modules/analysis/components/SingleResponseCard/util.test.ts b/apps/web/modules/analysis/components/SingleResponseCard/util.test.ts index ebfc8f55303f..d17354ab73f3 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/util.test.ts +++ b/apps/web/modules/analysis/components/SingleResponseCard/util.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { isSubmissionTimeMoreThan5Minutes, isValidValue } from "./util"; +import { isSubmissionTimeMoreThan5Minutes, isValidValue, splitRecallHighlights } from "./util"; describe("isValidValue", () => { test("returns false for an empty string", () => { @@ -49,3 +49,53 @@ describe("isSubmissionTimeMoreThan5Minutes", () => { expect(isSubmissionTimeMoreThan5Minutes(recentTime)).toBe(false); }); }); + +describe("splitRecallHighlights", () => { + // The regex this replaces, as the oracle every case is compared against. + const viaRegex = (text: string): string[] => text.split(/#\/(.*?)\\#/g); + + const FIXED = [ + "", + "plain text", + "before #/name\\# after", + "#/a\\# and #/b\\#", + "#/\\#", + "#/unclosed", + "#/spans\nnewline\\#", + "#/one\\#\n#/two\\#", + "text with \\# but no opener", + "#/back\\slash inside\\#", + "#/#/nested\\#", + "#/a\\#trailing", + "##//weird\\#", + "#/\r\n\\#", + "#/\u2028\\#", + // What a length cap got wrong: an over-long span containing another opener. + `#/${"a".repeat(2000)}#/short\\#tail`, + ]; + const ALPHABET = "#/\\ab\n\r "; + const random = Array.from({ length: 30000 }, () => { + const n = Math.floor(Math.random() * 24); + let s = ""; + for (let i = 0; i < n; i++) s += ALPHABET[Math.floor(Math.random() * ALPHABET.length)]; + return s; + }); + + test("matches the regex it replaces", () => { + for (const text of [...FIXED, ...random]) { + expect(splitRecallHighlights(text), `input: ${JSON.stringify(text)}`).toEqual(viaRegex(text)); + } + }); + + test("stays linear where the regex was quadratic", () => { + // `#/` repeated with no closing `\#`: the regex expands to the end from every opener. + const pathological = "#/".repeat(100000); + + const startedAt = performance.now(); + const parts = splitRecallHighlights(pathological); + const elapsedMs = performance.now() - startedAt; + + expect(parts).toEqual([pathological]); + expect(elapsedMs).toBeLessThan(500); + }); +}); diff --git a/apps/web/modules/analysis/components/SingleResponseCard/util.ts b/apps/web/modules/analysis/components/SingleResponseCard/util.ts index af76d0a68b61..f9bc4920e019 100644 --- a/apps/web/modules/analysis/components/SingleResponseCard/util.ts +++ b/apps/web/modules/analysis/components/SingleResponseCard/util.ts @@ -15,3 +15,60 @@ export const isSubmissionTimeMoreThan5Minutes = (submissionTimeISOString: Date) const timeDifference: number = (currentTime.getTime() - submissionTime.getTime()) / (1000 * 60); // Convert milliseconds to minutes return timeDifference > 5; }; + +const RECALL_HIGHLIGHT_OPEN = "#/"; +const RECALL_HIGHLIGHT_CLOSE = String.raw`\#`; + +/** The characters `.` excludes, so a highlighted span can never cross one. */ +const isLineTerminator = (character: string): boolean => + character === "\n" || character === "\r" || character === "\u2028" || character === "\u2029"; + +/** + * Split recall-highlighted text into alternating plain and highlighted parts, exactly as + * `text.split(/#\/(.*?)\\#/g)` did: even indices are plain text, odd indices are the highlighted + * spans. + * + * A scan rather than the regex. `(.*?)` is unbounded, so on a run of `#/` with no `\#` after it the + * engine expands to the end of the text from every one — O(N^2), measured 4.9s on 200k characters, + * over text that carries respondent-submitted answers into the admin's browser. Capping the span is + * not a fix: when the over-long span contains another `#/`, the match restarts there and highlights + * a different range. + * + * Linear because a failed span skips to the next line rather than to the next character: `.` cannot + * cross a line terminator, so if no `\#` precedes the next one, no later `#/` on that line has one + * either. + */ +export const splitRecallHighlights = (text: string): string[] => { + const parts: string[] = []; + let copiedTo = 0; + let searchFrom = 0; + + while (searchFrom <= text.length) { + const open = text.indexOf(RECALL_HIGHLIGHT_OPEN, searchFrom); + if (open === -1) break; + + const contentStart = open + RECALL_HIGHLIGHT_OPEN.length; + let close = -1; + let scan = contentStart; + while (scan < text.length && !isLineTerminator(text[scan])) { + if (text.startsWith(RECALL_HIGHLIGHT_CLOSE, scan)) { + close = scan; + break; + } + scan++; + } + + if (close === -1) { + // No close before the line ends, so nothing on the rest of this line can close either. + searchFrom = scan < text.length ? scan + 1 : text.length + 1; + continue; + } + + parts.push(text.slice(copiedTo, open), text.slice(contentStart, close)); + copiedTo = close + RECALL_HIGHLIGHT_CLOSE.length; + searchFrom = copiedTo; + } + + parts.push(text.slice(copiedTo)); + return parts; +}; diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts index a40664a0da5e..f36defe3f5af 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts @@ -378,7 +378,7 @@ export const updateUser = async ( const results = await prisma.$transaction(operations); // Retrieve the updated user result. Since the update was the last operation, it is the last item. - updatedUser = results[results.length - 1]; + updatedUser = results.at(-1); } await reconcileOrganizationMembership(organizationId, updatedUser.id); diff --git a/apps/web/modules/auth/components/back-to-login-button.tsx b/apps/web/modules/auth/components/back-to-login-button.tsx index 09b76074a642..9beba3c54e78 100644 --- a/apps/web/modules/auth/components/back-to-login-button.tsx +++ b/apps/web/modules/auth/components/back-to-login-button.tsx @@ -13,10 +13,10 @@ export const BackToLoginButton = async ({ callbackUrl }: Readonly<{ callbackUrl? const t = await getTranslate(); const href = callbackUrl ? `/auth/login?callbackUrl=${encodeURIComponent(callbackUrl)}` : "/auth/login"; return ( - ); }; diff --git a/apps/web/modules/auth/components/form-wrapper.tsx b/apps/web/modules/auth/components/form-wrapper.tsx index 3a8c49600ba7..48aa7ca10ab1 100644 --- a/apps/web/modules/auth/components/form-wrapper.tsx +++ b/apps/web/modules/auth/components/form-wrapper.tsx @@ -1,24 +1,37 @@ import Link from "next/link"; +import { getTranslate } from "@/lingodotdev/server"; import { Logo } from "@/modules/ui/components/logo"; interface FormWrapperProps { children: React.ReactNode; } -export const FormWrapper = ({ children }: FormWrapperProps) => { +/** + * The one shell every signed-out screen renders inside. It owns the backdrop and the + * centring so each auth page stays a bare form — before ENG-2428 login and signup each + * repeated their own full-screen wrapper and two routes hand-copied a second one. + * + * Mobile-first: `min-h-dvh` rather than `min-h-screen`, because `100vh` on mobile is the + * *large* viewport, which pushes a vertically centred card under the browser chrome. + */ +export const FormWrapper = async ({ children }: Readonly) => { + const t = await getTranslate(); + return ( -
    -
    +
    +
    - + rel="noopener noreferrer" + aria-label={t("common.formbricks_homepage")} + className="inline-block rounded-md focus-visible:ring-2 focus-visible:ring-brand-dark focus-visible:ring-offset-2 focus-visible:outline-hidden"> +
    {children} -
    +
    ); }; diff --git a/apps/web/modules/auth/email-change-without-verification-success/page.tsx b/apps/web/modules/auth/email-change-without-verification-success/page.tsx index 27c01458c4fd..799c6ad71539 100644 --- a/apps/web/modules/auth/email-change-without-verification-success/page.tsx +++ b/apps/web/modules/auth/email-change-without-verification-success/page.tsx @@ -14,15 +14,11 @@ export const EmailChangeWithoutVerificationSuccessPage = async () => { } return ( -
    - -

    - {t("auth.email-change.email_change_success")} -

    -

    {t("auth.email-change.email_change_success_description")}

    -
    - -
    -
    + +

    {t("auth.email-change.email_change_success")}

    +

    {t("auth.email-change.email_change_success_description")}

    +
    + +
    ); }; diff --git a/apps/web/modules/auth/forgot-password/components/forgot-password-form.tsx b/apps/web/modules/auth/forgot-password/components/forgot-password-form.tsx index 2bdeb3ca6424..34c7ef110792 100644 --- a/apps/web/modules/auth/forgot-password/components/forgot-password-form.tsx +++ b/apps/web/modules/auth/forgot-password/components/forgot-password-form.tsx @@ -10,7 +10,8 @@ import { z } from "zod"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { forgotPasswordAction } from "@/modules/auth/forgot-password/actions"; import { Button } from "@/modules/ui/components/button"; -import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form"; +import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form"; +import { Input } from "@/modules/ui/components/input"; const ZForgotPasswordForm = z.object({ email: z.email(), @@ -41,41 +42,41 @@ export const ForgotPasswordForm = () => { return (
    -
    - -
    - ( - - - field.onChange(e)} - autoComplete="email" - required - className="block w-full rounded-md border-slate-300 shadow-xs focus:border-brand-dark focus:ring-brand-dark sm:text-sm" - /> - - {error?.message && {error.message}} - - )} - /> -
    -
    + ( + + {t("common.email")} + + field.onChange(e)} + autoComplete="email" + inputMode="email" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + required + /> + + + + )} + />
    -
    -
    diff --git a/apps/web/modules/auth/forgot-password/reset/components/reset-password-form.tsx b/apps/web/modules/auth/forgot-password/reset/components/reset-password-form.tsx index 970d4467f2d1..879b4328242d 100644 --- a/apps/web/modules/auth/forgot-password/reset/components/reset-password-form.tsx +++ b/apps/web/modules/auth/forgot-password/reset/components/reset-password-form.tsx @@ -29,8 +29,7 @@ const passwordInputProps = { autoComplete: "new-password", placeholder: "*******", required: true, - className: - "focus:border-brand-dark focus:ring-brand-dark mt-2 block w-full rounded-md border-slate-300 shadow-xs sm:text-sm", + className: "focus:border-brand-dark focus:ring-brand-dark mt-2 block w-full rounded-md shadow-xs", }; export const ResetPasswordForm = () => { @@ -104,7 +103,7 @@ export const ResetPasswordForm = () => { diff --git a/apps/web/modules/auth/invite/components/content-layout.tsx b/apps/web/modules/auth/invite/components/content-layout.tsx index 68ca667c55d4..9401706ed3c6 100644 --- a/apps/web/modules/auth/invite/components/content-layout.tsx +++ b/apps/web/modules/auth/invite/components/content-layout.tsx @@ -4,14 +4,14 @@ interface ContentLayoutProps { children?: React.ReactNode; } -export const ContentLayout = ({ headline, description, children }: ContentLayoutProps) => { +export const ContentLayout = ({ headline, description, children }: Readonly) => { return ( -
    -
    -

    {headline}

    -

    {description}

    -
    {children}
    -
    +
    +
    +

    {headline}

    +

    {description}

    +
    {children}
    +
    ); }; diff --git a/apps/web/modules/auth/layout.tsx b/apps/web/modules/auth/layout.tsx index 3294740c3971..cbb0613be6ec 100644 --- a/apps/web/modules/auth/layout.tsx +++ b/apps/web/modules/auth/layout.tsx @@ -19,15 +19,15 @@ export const AuthLayout = async ({ children }: Readonly<{ children: React.ReactN if (isFreshInstance && !isMultiOrgEnabled) { redirect("/setup/intro"); } + + // The backdrop and centring live in FormWrapper so the routes outside this layout + // (/invite, /verify-email-change, /email-change-without-verification-success) get + // the same shell instead of hand-copying one. return ( <> -
    -
    -
    {children}
    -
    -
    + {children} ); }; diff --git a/apps/web/modules/auth/lib/better-auth-observability.ts b/apps/web/modules/auth/lib/better-auth-observability.ts index c791a6967bee..694193c0289c 100644 --- a/apps/web/modules/auth/lib/better-auth-observability.ts +++ b/apps/web/modules/auth/lib/better-auth-observability.ts @@ -269,6 +269,67 @@ const getStateErrorCode = (cause: Error | undefined): string | undefined => { const isUnactionableStateError = (code: string | undefined): boolean => code !== undefined && UNACTIONABLE_STATE_ERROR_CODES.has(code); +/** + * Emit a Better Auth `warn`-level log, attaching the safe error context when the entry carried an + * `Error`. + * + * `safeMessage` is `unknown` because Better Auth's logger signature is: the message is usually a + * string, but a couple of call sites pass the `Error` itself. pino's two-argument form needs a string + * in the message slot, hence the narrowing — a non-string falls back to a fixed label rather than + * being stringified, since the `Error` is already carried by the context object. + */ +const logWarning = ( + contextLogger: ReturnType, + safeMessage: unknown, + cause: Error | undefined +): void => { + if (!cause) { + contextLogger.warn(safeMessage); + return; + } + + contextLogger.warn( + getSafeWarningErrorContext(cause), + typeof safeMessage === "string" ? safeMessage : "Better Auth warning" + ); +}; + +/** + * Capture a Better Auth `error`-level log to Sentry, but only when it is a GENUINE internal fault. + * + * Split out of `betterAuthLogger.log` rather than inlined: the three stacked conditions below carried + * most of that function's branching, and the decision "does this page?" is a separate concern from + * "how is this logged". Behaviour is unchanged — the gate, its order, and the tag set are the same. + * + * Skips handled rejections: a bare string code (no Error), a client-facing APIError, or a + * client/timing-caused OAuth `StateError` (ENG-2471) — so Sentry stays actionable (see the reason-split + * on `betterAuthLogger` and UNACTIONABLE_STATE_ERROR_CODES). + */ +const captureInternalAuthFault = ( + cause: Error | undefined, + stateErrorCode: string | undefined, + request: ReturnType +): void => { + if (!SENTRY_DSN || !IS_PRODUCTION) return; + if (!cause || isAPIError(cause) || isUnactionableStateError(stateErrorCode)) return; + + // ENG-2259: Better Auth's router logs a non-APIError as `(e.name, e)` and discards the endpoint + // (`better-auth/dist/api/index.mjs:210`), so a bare capture arrives with no transaction, URL or + // route — which is why FORMBRICKS-183 sat at ~242 events untriageable. Tags don't affect grouping, + // so the issue stays one issue with `auth.path` as a facet. + Sentry.captureException(cause, { + tags: { + component: "better-auth", + ...(request && { "auth.path": request.path, "http.method": request.method }), + }, + // No `extra`. Forwarding `message` was considered and dropped: `redactEmailsInLogMessage` strips + // emails and nothing else, so a plugin logging `error("… …", err)` would put that token in + // Sentry verbatim — while the message adds nothing, being either the error's own name or a + // sentence accompanying the Error already captured here. Keeping Sentry to `Error`-or-nothing is + // what makes the header note above stay true. + }); +}; + /** * Route Better Auth's logger to @formbricks/logger and capture GENUINE internal faults to Sentry in * production — replaces auth.ts's placeholder logger (and the route's Sentry.captureException on auth @@ -313,37 +374,9 @@ export const betterAuthLogger: NonNullable = { const safeMessage = redactEmailsInLogMessage(message); if (level === "error") { contextLogger.error(safeMessage); - if (SENTRY_DSN && IS_PRODUCTION) { - // Skip handled rejections: a bare string code (no Error), a client-facing APIError, or a - // client/timing-caused OAuth `StateError` (ENG-2471). Capture only genuine internal faults so - // Sentry stays actionable (see the reason-split above and UNACTIONABLE_STATE_ERROR_CODES). - if (cause && !isAPIError(cause) && !isUnactionableStateError(stateErrorCode)) { - // ENG-2259: Better Auth's router logs a non-APIError as `(e.name, e)` and discards the - // endpoint (`better-auth/dist/api/index.mjs:210`), so a bare capture arrives with no - // transaction, URL or route — which is why FORMBRICKS-183 sat at ~242 events untriageable. - // Tags don't affect grouping, so the issue stays one issue with `auth.path` as a facet. - Sentry.captureException(cause, { - tags: { - component: "better-auth", - ...(request && { "auth.path": request.path, "http.method": request.method }), - }, - // No `extra`. Forwarding `message` was considered and dropped: `redactEmailsInLogMessage` - // strips emails and nothing else, so a plugin logging `error("… …", err)` would - // put that token in Sentry verbatim — while the message adds nothing, being either the - // error's own name or a sentence accompanying the Error already captured here. Keeping - // Sentry to `Error`-or-nothing is what makes the header note above stay true. - }); - } - } + captureInternalAuthFault(cause, stateErrorCode, request); } else if (level === "warn") { - if (cause) { - contextLogger.warn( - getSafeWarningErrorContext(cause), - typeof safeMessage === "string" ? safeMessage : "Better Auth warning" - ); - } else { - contextLogger.warn(safeMessage); - } + logWarning(contextLogger, safeMessage, cause); } else { contextLogger.info(safeMessage); } diff --git a/apps/web/modules/auth/lib/better-auth-path-label.test.ts b/apps/web/modules/auth/lib/better-auth-path-label.test.ts index 6f466a184529..1604b242dbfe 100644 --- a/apps/web/modules/auth/lib/better-auth-path-label.test.ts +++ b/apps/web/modules/auth/lib/better-auth-path-label.test.ts @@ -110,6 +110,34 @@ describe("createAuthPathLabeller — labelling rules", () => { expect(label("https://app.formbricks.com/api/auth/sign-in/email//")).toBe("/sign-in/email"); }); + test("a pathological run of slashes stays linear (no catastrophic backtracking)", () => { + // Regression guard for the super-linear `replace(/\/+$/, "")` this file used to trim with: the + // greedy `\/+` was unanchored at the start, so on a slash run not followed by end-of-string the + // engine retried from every offset — O(N^2) on a value taken straight from the request URL. + // At this size the old form took ~2.8s locally against ~0.004ms for the reverse scan, so the + // budget below is a >5x margin over the slowest plausible CI machine and nowhere near the + // regressed cost. + const pathological = `https://app.formbricks.com/api/auth/${"/".repeat(100_000)}x`; + + const startedAt = performance.now(); + const result = label(pathological); + const elapsedMs = performance.now() - startedAt; + + expect(result).toBe(UNKNOWN_AUTH_PATH_LABEL); + expect(elapsedMs).toBeLessThan(500); + }); + + test("trailing-slash trimming matches the regex it replaced, including the edge shapes", () => { + // The reverse scan has to be exactly `replace(/\/+$/, "")`: same result on no slashes, one, many, + // and an all-slash path (where it must not walk past index 0). + expect(label("https://app.formbricks.com/api/auth/get-session")).toBe("/get-session"); + expect(label("https://app.formbricks.com/api/auth/get-session/")).toBe("/get-session"); + expect(label(`https://app.formbricks.com/api/auth/get-session${"/".repeat(50)}`)).toBe("/get-session"); + // Path reduces to "" — the loop must stop at index 0 rather than underflow. + expect(label("https://app.formbricks.com/api/auth///")).toBe(UNKNOWN_AUTH_PATH_LABEL); + expect(label("https://app.formbricks.com/api/auth")).toBe(UNKNOWN_AUTH_PATH_LABEL); + }); + test("a truncated label never collides with the same-named literal endpoint", () => { // `/reset-password` (POST, performs the reset) and `/reset-password/:token` (GET callback) are // different endpoints; merging them into one bucket would lose the distinction that matters when diff --git a/apps/web/modules/auth/lib/better-auth-path-label.ts b/apps/web/modules/auth/lib/better-auth-path-label.ts index f0a9701667bd..a9e3c4ca5d26 100644 --- a/apps/web/modules/auth/lib/better-auth-path-label.ts +++ b/apps/web/modules/auth/lib/better-auth-path-label.ts @@ -42,6 +42,21 @@ export const UNKNOWN_AUTH_PATH_LABEL = "unknown"; const getFirstSegment = (path: string): string | undefined => path.split("/")[1] || undefined; +/** + * Linear-time trailing-slash trim. + * + * Not `replace(/\/+$/, "")`: that pattern is super-linear. The leading `\/+` is greedy and + * unanchored at the start, so on a run of N slashes the engine retries from every offset and + * backtracks the whole run each time — O(N^2). `pathname` here comes straight off the request URL, + * which is attacker-controlled, so `/api/auth` + a few thousand slashes would burn CPU per request. + * A single reverse scan does the same job in one pass and one allocation. + */ +const trimTrailingSlashes = (path: string): string => { + let end = path.length; + while (end > 0 && path[end - 1] === "/") end--; + return path.slice(0, end); +}; + export const createAuthPathLabeller = (declaredPaths: Iterable): ((url: string) => string) => { // Only parameter-free declared paths may be emitted verbatim. Patterns still contribute their first // segment, so `/reset-password/` degrades to a recognized `/reset-password/*` rather than to @@ -72,7 +87,7 @@ export const createAuthPathLabeller = (declaredPaths: Iterable): ((url: // Trailing slashes are trimmed before matching: the declared set holds `/get-session`, so // `/api/auth/get-session/` would otherwise miss the exact match and degrade to `/get-session/*`, // splitting one endpoint across two facet values for nothing. - const authPath = pathname.slice(baseIndex + AUTH_BASE_PATH.length).replace(/\/+$/, ""); + const authPath = trimTrailingSlashes(pathname.slice(baseIndex + AUTH_BASE_PATH.length)); if (literalPaths.has(authPath)) return authPath; // `//*`, not a bare segment: the emitted values all land in one Sentry facet, so a mix of diff --git a/apps/web/modules/auth/lib/oauth-urls.ts b/apps/web/modules/auth/lib/oauth-urls.ts index bd5aa0965e05..fbe0d5baf113 100644 --- a/apps/web/modules/auth/lib/oauth-urls.ts +++ b/apps/web/modules/auth/lib/oauth-urls.ts @@ -6,7 +6,15 @@ const AUTH_BASE_PATH = "/api/auth"; const MCP_RESOURCE_PATH = "/api/mcp"; const MCP_PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource/api/mcp"; -const trimTrailingSlash = (value: string): string => value.replace(/\/+$/, ""); +// A reverse scan, not `replace(/\/+$/, "")`: that pattern is greedy and unanchored at the start, so +// on a run of N slashes the engine retries from every offset — O(N^2). Only ever fed configured +// values here (WEBAPP_URL / BETTER_AUTH_URL / NEXTAUTH_URL), so this is not the reachable case that +// better-auth-path-label.ts fixes — it is the same shape, kept off the codebase for good. +const trimTrailingSlash = (value: string): string => { + let end = value.length; + while (end > 0 && value[end - 1] === "/") end--; + return value.slice(0, end); +}; const normalizeConfiguredUrl = (value: string | undefined, fallback = DEFAULT_WEBAPP_URL): URL => { const configured = value?.trim() || fallback; diff --git a/apps/web/modules/auth/login/components/login-form.tsx b/apps/web/modules/auth/login/components/login-form.tsx index 10d489a459f8..05915ea31cb7 100644 --- a/apps/web/modules/auth/login/components/login-form.tsx +++ b/apps/web/modules/auth/login/components/login-form.tsx @@ -17,7 +17,8 @@ import { TwoFactor } from "@/modules/ee/two-factor-auth/components/two-factor"; import { TwoFactorBackup } from "@/modules/ee/two-factor-auth/components/two-factor-backup"; import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; import { Button } from "@/modules/ui/components/button"; -import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form"; +import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form"; +import { Input } from "@/modules/ui/components/input"; import { PasswordInput } from "@/modules/ui/components/password-input"; const ZLoginForm = z.object({ @@ -193,7 +194,7 @@ export const LoginForm = ({ return (
    -

    {formLabel}

    +

    {formLabel}

    {emailJustVerified && ( {t("auth.login.email_verified_sign_in_title")} @@ -219,57 +220,56 @@ export const LoginForm = ({ ( - + render={({ field }) => ( + + {t("common.email")} -
    - field.onChange(email)} - placeholder="work@email.com" - className="block w-full rounded-md border-slate-300 shadow-xs focus:border-brand-dark focus:ring-brand-dark sm:text-sm" - /> - {error?.message && {error.message}} -
    + field.onChange(email)} + placeholder="work@email.com" + />
    +
    )} /> ( - + render={({ field }) => ( + + {t("common.password")} -
    - field.onChange(password)} - /> - {error?.message && {error.message}} -
    + field.onChange(password)} + />
    +
    )} /> {passwordResetEnabled && ( -
    +
    + className="inline-flex min-h-6 items-center rounded-sm py-1 text-sm text-slate-500 hover:text-brand-dark focus-visible:ring-2 focus-visible:ring-brand-dark focus-visible:outline-hidden"> {t("auth.login.forgot_your_password")}
    @@ -288,11 +288,13 @@ export const LoginForm = ({ setTimeout(() => emailRef.current?.focus(), 100); } } - className="relative w-full justify-center" + className="h-11 w-full min-w-0 justify-center sm:h-9" loading={form.formState.isSubmitting}> - {totpLogin ? t("common.submit") : t("auth.login.login_with_email")} + + {totpLogin ? t("common.submit") : t("auth.login.login_with_email")} + {lastLoggedInWith && lastLoggedInWith === "Email" ? ( - {t("auth.last_used")} + {t("auth.last_used")} ) : null} )} @@ -315,7 +317,9 @@ export const LoginForm = ({
    {t("auth.login.new_to_formbricks")}
    - + {t("auth.login.create_an_account")}
    @@ -329,7 +333,7 @@ export const LoginForm = ({
    @@ -380,7 +397,9 @@ export const SignupForm = ({
    {t("auth.signup.have_an_account")}
    - + {t("auth.signup.log_in")}
    diff --git a/apps/web/modules/auth/signup/components/terms-privacy-links.tsx b/apps/web/modules/auth/signup/components/terms-privacy-links.tsx index 9623b5cfa96a..28f4d6c5ca03 100644 --- a/apps/web/modules/auth/signup/components/terms-privacy-links.tsx +++ b/apps/web/modules/auth/signup/components/terms-privacy-links.tsx @@ -8,25 +8,33 @@ interface TermsPrivacyLinksProps { privacyUrl?: string; } -export const TermsPrivacyLinks = ({ termsUrl, privacyUrl }: TermsPrivacyLinksProps) => { +export const TermsPrivacyLinks = ({ termsUrl, privacyUrl }: Readonly) => { const { t } = useTranslation(); if (!termsUrl && !privacyUrl) return null; return ( -
    +
    {termsUrl && ( - + {t("auth.signup.terms_of_service")} )} {termsUrl && privacyUrl && {t("common.and")} } {privacyUrl && ( - + {t("auth.signup.privacy_policy")} )} -
    +
    ); }; diff --git a/apps/web/modules/auth/signup/page.tsx b/apps/web/modules/auth/signup/page.tsx index cf68c69b0f14..86dc09ecb787 100644 --- a/apps/web/modules/auth/signup/page.tsx +++ b/apps/web/modules/auth/signup/page.tsx @@ -58,28 +58,26 @@ export const SignupPage = async ({ const emailFromSearchParams = searchParams["email"]; return ( -
    - - - -
    + + + ); }; diff --git a/apps/web/modules/auth/verification-requested/components/verification-message.tsx b/apps/web/modules/auth/verification-requested/components/verification-message.tsx index 159c551168d9..8a728d98e5ba 100644 --- a/apps/web/modules/auth/verification-requested/components/verification-message.tsx +++ b/apps/web/modules/auth/verification-requested/components/verification-message.tsx @@ -6,9 +6,9 @@ interface VerificationMessageProps { email: string; } -export const VerificationMessage = ({ email }: VerificationMessageProps) => { +export const VerificationMessage = ({ email }: Readonly) => { return ( -

    +

    - - - - -

    + + + + ); }; diff --git a/apps/web/modules/ee/license-check/lib/license.test.ts b/apps/web/modules/ee/license-check/lib/license.test.ts index 47974ea02905..8e465e2391b3 100644 --- a/apps/web/modules/ee/license-check/lib/license.test.ts +++ b/apps/web/modules/ee/license-check/lib/license.test.ts @@ -259,7 +259,7 @@ describe("License Core Logic", () => { const second = await getEnterpriseLicense(); expect(second).toEqual(first); - expect(mockCache.get.mock.calls.length).toBe(cacheReadsAfterFirstCall); + expect(mockCache.get.mock.calls).toHaveLength(cacheReadsAfterFirstCall); } finally { envMock.NODE_ENV = "test"; } diff --git a/apps/web/modules/ee/sso/components/azure-button.tsx b/apps/web/modules/ee/sso/components/azure-button.tsx index 30c511d9384a..bc67346019b4 100644 --- a/apps/web/modules/ee/sso/components/azure-button.tsx +++ b/apps/web/modules/ee/sso/components/azure-button.tsx @@ -49,8 +49,12 @@ export const AzureButton = ({ }, [directRedirect, handleLogin]); return ( - diff --git a/apps/web/modules/ee/sso/components/github-button.tsx b/apps/web/modules/ee/sso/components/github-button.tsx index cce977c498ae..2f7c1f2376ae 100644 --- a/apps/web/modules/ee/sso/components/github-button.tsx +++ b/apps/web/modules/ee/sso/components/github-button.tsx @@ -36,8 +36,12 @@ export const GithubButton = ({ }; return ( - diff --git a/apps/web/modules/ee/sso/components/google-button.tsx b/apps/web/modules/ee/sso/components/google-button.tsx index 3adb6b2af37d..226b803a042a 100644 --- a/apps/web/modules/ee/sso/components/google-button.tsx +++ b/apps/web/modules/ee/sso/components/google-button.tsx @@ -36,8 +36,12 @@ export const GoogleButton = ({ }; return ( - diff --git a/apps/web/modules/ee/sso/components/open-id-button.tsx b/apps/web/modules/ee/sso/components/open-id-button.tsx index ce708dfb8b14..3d15f3f4fd38 100644 --- a/apps/web/modules/ee/sso/components/open-id-button.tsx +++ b/apps/web/modules/ee/sso/components/open-id-button.tsx @@ -54,7 +54,7 @@ export const OpenIdButton = ({ type="button" onClick={handleLogin} variant={variant} - className="w-full items-center justify-center gap-2 px-2"> + className="h-11 w-full min-w-0 items-center justify-center gap-2 px-2 sm:h-9"> {text || t("auth.continue_with_openid")} {lastUsed && {t("auth.last_used")}} diff --git a/apps/web/modules/ee/sso/components/saml-button.tsx b/apps/web/modules/ee/sso/components/saml-button.tsx index 0d5b1c972a0e..5ed089ff46ab 100644 --- a/apps/web/modules/ee/sso/components/saml-button.tsx +++ b/apps/web/modules/ee/sso/components/saml-button.tsx @@ -53,12 +53,11 @@ export const SamlButton = ({ returnToUrl, lastUsed, source }: Readonly - {t("auth.continue_with_saml")} - + {t("auth.continue_with_saml")} - {lastUsed && {t("auth.last_used")}} + {lastUsed && {t("auth.last_used")}} ); }; diff --git a/apps/web/modules/ee/workflows/components/inspector/workflow-email-action-form.tsx b/apps/web/modules/ee/workflows/components/inspector/workflow-email-action-form.tsx index 06cbecb27fdc..35916dbd1812 100644 --- a/apps/web/modules/ee/workflows/components/inspector/workflow-email-action-form.tsx +++ b/apps/web/modules/ee/workflows/components/inspector/workflow-email-action-form.tsx @@ -38,7 +38,18 @@ interface WorkflowEmailActionFormProps { // The internal "default language" slot recall/headline resolution uses when no language is selected. const DEFAULT_LANGUAGE_CODE = "default"; -const HTML_TAG_PATTERN = /<[a-z][\s\S]*>/i; +// Index scans, not `/<[a-z][\s\S]*>/i`: that pattern is O(N^2) on a long run of ``, +// since the greedy `[\s\S]*` rescans to the end from every one. Identical predicate — `[\s\S]*` +// matches anything, so the regex holds exactly when some `<` + letter is followed later by any `>`, +// i.e. when the LAST `>` sits after the FIRST ` { + const openingTag = HTML_OPENING_TAG_PATTERN.exec(value); + if (!openingTag) return false; + + return value.lastIndexOf(">") > openingTag.index + 1; +}; const escapeHtml = (value: string): string => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); @@ -49,7 +60,7 @@ const escapeHtml = (value: string): string => // node ("Only element or decorator nodes can be inserted to the root node"). const toEditorHtml = (body: string): string => { if (!body) return ""; - if (HTML_TAG_PATTERN.test(body)) return body; + if (containsHtmlTag(body)) return body; return body .split(/\n{2,}/) .map((paragraph) => `

    ${escapeHtml(paragraph).replaceAll("\n", "
    ")}

    `) diff --git a/apps/web/modules/ee/workflows/components/inspector/workflow-node-config-panel.tsx b/apps/web/modules/ee/workflows/components/inspector/workflow-node-config-panel.tsx index aa4f38fba352..1c7bba5af606 100644 --- a/apps/web/modules/ee/workflows/components/inspector/workflow-node-config-panel.tsx +++ b/apps/web/modules/ee/workflows/components/inspector/workflow-node-config-panel.tsx @@ -81,14 +81,13 @@ export const WorkflowNodeConfigPanel = ({ isEditable }: Readonly = { + readOnly: t("workspace.workflows.edit_blocked_read_only"), + archived: t("workspace.workflows.edit_blocked_archived"), + active: t("workspace.workflows.edit_blocked_active"), + }; + const blockedReason = blockedReasonType ? blockedReasonMessages[blockedReasonType] : null; const registryEntry = getNodeRegistryEntry(selectedNode); const ConfigForm = registryEntry.ConfigForm; diff --git a/apps/web/modules/email/lib/preview-email-template-styles.ts b/apps/web/modules/email/lib/preview-email-template-styles.ts index 31af7aa7805a..94ca05886d8b 100644 --- a/apps/web/modules/email/lib/preview-email-template-styles.ts +++ b/apps/web/modules/email/lib/preview-email-template-styles.ts @@ -2,6 +2,7 @@ import type { CSSProperties } from "react"; import type { TSurveyStyling } from "@formbricks/types/surveys/types"; import { COLOR_DEFAULTS, STYLE_DEFAULTS } from "@/lib/styling/constants"; import { isLight, mixColor } from "@/lib/utils/colors"; +import { replaceOpeningTags } from "@/lib/utils/html-opening-tag"; import { NESTED_LIST_ITEM_CLASS, NESTED_LIST_ITEM_MARKER_STYLE, @@ -76,8 +77,6 @@ const EMAIL_PREVIEW_ACCENT_COLORS = { "rose-100": "#ffe4e6", } as const; -const RICH_TEXT_PARAGRAPH_TAG_REGEX = /]*)>/gi; -const RICH_TEXT_LIST_ITEM_TAG_REGEX = /]*)>/gi; const RICH_TEXT_STYLE_ATTRIBUTE_REGEX = /\sstyle=(["'])(.*?)\1/i; const RICH_TEXT_STYLE_ATTRIBUTE_REPLACE_REGEX = /\sstyle=(["'])(.*?)\1/gi; const RICH_TEXT_CLASS_ATTRIBUTE_REGEX = /\sclass=(["'])(.*?)\1/i; @@ -85,7 +84,7 @@ const RICH_TEXT_CLASS_ATTRIBUTE_REGEX = /\sclass=(["'])(.*?)\1/i; export const importantStyle = (value: string): string => `${value} !important`; export const normalizeRichTextSpacing = (html: string): string => - html.replaceAll(RICH_TEXT_PARAGRAPH_TAG_REGEX, (_tag, attributes: string = "") => { + replaceOpeningTags(html, "p", (attributes) => { if (RICH_TEXT_STYLE_ATTRIBUTE_REGEX.test(attributes)) { return ` * list item (best effort: legacy Outlook's Word engine ignores list-style-type). */ export const suppressNestedListMarkers = (html: string): string => - html.replaceAll(RICH_TEXT_LIST_ITEM_TAG_REGEX, (tag: string, attributes: string = "") => { + replaceOpeningTags(html, "li", (attributes, tag) => { const classMatch = RICH_TEXT_CLASS_ATTRIBUTE_REGEX.exec(attributes); const classNames = classMatch ? classMatch[2].split(/\s+/) : []; if (!classNames.includes(NESTED_LIST_ITEM_CLASS)) { diff --git a/apps/web/modules/settings/lib/metadata.test.ts b/apps/web/modules/settings/lib/metadata.test.ts new file mode 100644 index 000000000000..60dfe77af8f1 --- /dev/null +++ b/apps/web/modules/settings/lib/metadata.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { getSettingsPageMetadata } from "./metadata"; + +const translate = vi.fn(); + +vi.mock("@/lingodotdev/server", () => ({ + getTranslate: () => Promise.resolve(translate), +})); + +describe("getSettingsPageMetadata", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("titles the tab with the translated heading", async () => { + translate.mockReturnValue("Tags"); + + const metadata = await getSettingsPageMetadata("common.tags"); + + expect(translate).toHaveBeenCalledWith("common.tags"); + expect(metadata).toEqual({ title: "Tags" }); + }); + + test("uses the translation as-is, so the tab matches what the page shows", async () => { + translate.mockReturnValue("Survey Languages"); + + const metadata = await getSettingsPageMetadata("common.survey_languages"); + + expect(metadata.title).toBe("Survey Languages"); + }); +}); diff --git a/apps/web/modules/settings/lib/metadata.ts b/apps/web/modules/settings/lib/metadata.ts new file mode 100644 index 000000000000..363e5985e3a9 --- /dev/null +++ b/apps/web/modules/settings/lib/metadata.ts @@ -0,0 +1,16 @@ +import { Metadata } from "next"; +import { getTranslate } from "@/lingodotdev/server"; + +/** + * Browser tab title for a settings page: pass the same translation key the page renders as its + * heading, so the tab and the sidebar always say the same thing. Titles have to resolve per request + * because they are translated, which a static `metadata` object cannot do. + * + * Each settings page names itself rather than inheriting one title for the whole section. The + * section-wide title this replaced still said "Configuration" long after the UI stopped using that + * word anywhere — and it reached the product docs from there. + */ +export const getSettingsPageMetadata = async (headingKey: string): Promise => { + const t = await getTranslate(); + return { title: t(headingKey) }; +}; diff --git a/apps/web/modules/setup/layout.tsx b/apps/web/modules/setup/layout.tsx index d4fc0e087c39..a936bf3bc254 100644 --- a/apps/web/modules/setup/layout.tsx +++ b/apps/web/modules/setup/layout.tsx @@ -5,10 +5,10 @@ export const SetupLayout = ({ children }: { children: React.ReactNode }) => { return ( <> -
    +
    + className="flex max-h-[90dvh] w-full max-w-160 flex-col items-center gap-y-4 overflow-auto rounded-lg border bg-white p-6 text-center shadow-md sm:p-12">
    diff --git a/apps/web/modules/ui/components/editor/components/auto-link-matchers.test.ts b/apps/web/modules/ui/components/editor/components/auto-link-matchers.test.ts index d595416fcb9d..59d90b584adc 100644 --- a/apps/web/modules/ui/components/editor/components/auto-link-matchers.test.ts +++ b/apps/web/modules/ui/components/editor/components/auto-link-matchers.test.ts @@ -53,5 +53,41 @@ describe("auto-link matchers", () => { test("returns null when there is no email address", () => { expect(matchEmail("no address in here")).toBeNull(); }); + + // The local part is capped at RFC 5321's 64 so a long run of local-part characters cannot be + // rescanned from every start position. A cap on its own is not enough: `\b` also sits between a + // word character and `+`, `-`, `%` or `.`, so the match could restart INSIDE an overlong local + // part and link a different address than the one written. These pin "no link" rather than + // "a link to something else". + test.each([ + ["plus", "a+"], + ["dot", "a."], + ["hyphen", "a-"], + ["percent", "a%"], + ["underscore", "a_"], + ])("does not link a truncated suffix of an overlong local part (%s)", (_label, unit) => { + // 32 repeats + a trailing "a" is a 65-character local part, one over the cap, with an + // interior word boundary after every punctuation character. + const overlong = `${unit.repeat(32)}a@example.com`; + + expect(matchEmail(overlong)).toBeNull(); + }); + + test("links a local part exactly at the 64-character cap", () => { + const atCap = `${"a".repeat(64)}@example.com`; + + expect(matchEmail(atCap)).toMatchObject({ index: 0, text: atCap }); + }); + + test("keeps linking ordinary addresses that contain local-part punctuation", () => { + for (const address of [ + "first.last+tag@sub.example.co.uk", + "user_name@example.org", + "user-name@example-host.io", + "percent%sign@example.com", + ]) { + expect(matchEmail(address)).toMatchObject({ index: 0, text: address }); + } + }); }); }); diff --git a/apps/web/modules/ui/components/editor/components/auto-link-matchers.ts b/apps/web/modules/ui/components/editor/components/auto-link-matchers.ts index ba2b607a7f03..396d3590c158 100644 --- a/apps/web/modules/ui/components/editor/components/auto-link-matchers.ts +++ b/apps/web/modules/ui/components/editor/components/auto-link-matchers.ts @@ -1,9 +1,24 @@ import type { LinkMatcher } from "@lexical/link"; +// `{1,256}` already keeps this one linear (measured 27ms on 200k characters), so it is unchanged. const URL_MATCHER = /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/; -const EMAIL_MATCHER = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/; +// Unbounded, the engine rescans a long run of local-part characters from every start position +// looking for an `@` that never comes — O(N^2), measured 17s on 200k characters of `%_-`. +// +// The lookbehind is what makes this safe, and the cap alone is NOT enough. `\b` also sits between a +// word character and `+`, `-`, `%` or `.`, so with only a cap the engine can restart *inside* an +// overlong local part and match its last 64 characters: `("a+".repeat(32) + "a@example.com")` linked +// `+a+a…@example.com`, a DIFFERENT address from the one written. Refusing to start where a +// local-part character precedes means an overlong local part matches nowhere at all, so the worst +// case is no link rather than a link to somewhere else. +// +// Together they are also linear (0.5ms on the same 200k input), because the lookbehind lets the +// engine skip every start position inside a run instead of retrying each one. +// +// Bounding the domain and TLD as well measured no faster on any pump tried, so those stay unbounded. +const EMAIL_MATCHER = /\b(?(({ className, isInv return ( { + const { t } = useTranslation(); const valueItems = useMemo(() => { const valueArray = value.split(""); const items: Array = []; @@ -129,7 +131,11 @@ export const OTPInput = ({ }; return ( -
    + // Fluid boxes rather than a fixed w-10: six 40px boxes plus five 8px gaps need 280px, + // which overflows the auth card below 375px and by 56px on a 320px screen (ENG-2428). + // Kept as a flex row of direct children because the focus handlers walk + // nextElementSibling/previousElementSibling. +
    {valueItems.map((digit, idx) => ( , "type"> { @@ -11,6 +12,7 @@ export interface PasswordInputProps extends Omit( ({ className, containerClassName, ...rest }, ref) => { const [showPassword, setShowPassword] = useState(false); + const { t } = useTranslation(); const togglePasswordVisibility = () => { setShowPassword((prevShowPassword) => !prevShowPassword); @@ -21,20 +23,22 @@ const PasswordInput = forwardRef( ref={ref} type={showPassword ? "text" : "password"} className={cn( - "flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50", + // text-base below sm keeps iOS Safari from zooming the viewport on focus (ENG-2428); + // pr-11 reserves room for the toggle's tap target rather than just its icon. + "flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 pr-11 text-base text-slate-800 placeholder:text-slate-500 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 sm:text-sm", className )} {...rest} />
    ); diff --git a/apps/web/modules/ui/globals.css b/apps/web/modules/ui/globals.css index 8dd525557a72..8bbc92fe8016 100644 --- a/apps/web/modules/ui/globals.css +++ b/apps/web/modules/ui/globals.css @@ -194,6 +194,12 @@ background-image: radial-gradient(var(--tw-gradient-stops)); } +/* Backdrop behind the signed-out (auth) screens. Before ENG-2428 this hex was inline on + login and signup only, while the other auth routes showed a slate gradient. */ +@utility bg-auth-backdrop { + background-color: #d9f6f4; +} + /* The default border color has changed to `currentcolor` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still diff --git a/apps/web/modules/workspaces/settings/layout.tsx b/apps/web/modules/workspaces/settings/layout.tsx index 9a29bdfb3aca..6ee11fefb124 100644 --- a/apps/web/modules/workspaces/settings/layout.tsx +++ b/apps/web/modules/workspaces/settings/layout.tsx @@ -1,13 +1,8 @@ -import { Metadata } from "next"; import { redirect } from "next/navigation"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { getBillingFallbackPath } from "@/lib/membership/navigation"; import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils"; -export const metadata: Metadata = { - title: "Configuration", -}; - export const WorkspaceSettingsLayout = async (props: { params: Promise<{ workspaceId: string }>; children: React.ReactNode; diff --git a/apps/web/package.json b/apps/web/package.json index 675090acbdaf..9178b60de658 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -46,6 +46,7 @@ "@formbricks/js-core": "workspace:*", "@formbricks/logger": "workspace:*", "@formbricks/storage": "workspace:*", + "@formbricks/survey-ui": "workspace:*", "@formbricks/surveys": "workspace:*", "@formbricks/types": "workspace:*", "@formbricks/workflows": "workspace:*", diff --git a/apps/web/playwright/settings-tags.spec.ts b/apps/web/playwright/settings-tags.spec.ts index 22aecdd2bf07..766d9cb9642a 100644 --- a/apps/web/playwright/settings-tags.spec.ts +++ b/apps/web/playwright/settings-tags.spec.ts @@ -45,6 +45,11 @@ test.describe("Workspace tags settings @slow", () => { waitUntil: "domcontentloaded", }); + // The tab title used to come from one section-wide `metadata` that still said "Configuration" — a + // word the UI shows nowhere, and the phrase the product docs picked up from it. Each settings page + // now titles itself with the heading it renders, so assert the two agree on the way past. + await expect(page).toHaveTitle("Tags | Formbricks"); + // Rows are addressed by the id the API returned, and names are read with `toHaveValue`, which reads // the live value. An `input[value="…"]` selector would match the *attribute* instead — `fill()` never // updates that, so such a locator passes before a rename and fails after one for the wrong reason. diff --git a/apps/web/playwright/signup.spec.ts b/apps/web/playwright/signup.spec.ts index b3628a3ace45..52bd2012d9ce 100644 --- a/apps/web/playwright/signup.spec.ts +++ b/apps/web/playwright/signup.spec.ts @@ -1,4 +1,5 @@ -import { expect } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { type Page, expect } from "@playwright/test"; import { test } from "./lib/fixtures"; import { mockUsers } from "./utils/mock"; @@ -59,3 +60,60 @@ test.describe("Email Signup Flow Test", async () => { await expect(button).toBeDisabled(); }); }); + +// ENG-2428. The signed-out screens used to be covered by NoMobileOverlay below 640px, so +// nothing here could run at a phone width. These two guard what removing it bought: the +// layout reflows to 320px (WCAG 1.4.10), and the form is operable and free of AA violations. +const MOBILE = { width: 375, height: 812 }; + +// The same WCAG AA set survey-accessibility.spec.ts gates on. +const WCAG_AA_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22a", "wcag22aa"]; + +const horizontalOverflow = (page: Page) => + page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + +test.describe("Signed-out screens on a phone", () => { + test("reflow to 320px without horizontal scrolling", async ({ page }) => { + // Two widths, each with a reason: 320 is the WCAG 1.4.10 floor, and 430 is the repo's + // own `xs` breakpoint — the only place the auth surface changes behaviour between them. + for (const width of [320, 430]) { + await page.setViewportSize({ width, height: 812 }); + + for (const path of ["/auth/login", "/auth/forgot-password", "/auth/signup"]) { + await page.goto(path); + await page.waitForLoadState("load"); + expect(await horizontalOverflow(page), `${path} at ${width}px`).toBe(0); + } + + // The email step is what actually fills the card — six SSO buttons, two fields and a + // captcha only exist once it is expanded. + await page.goto("/auth/login"); + await page.getByRole("button", { name: "Log in with Email" }).dispatchEvent("click"); + await expect(page.getByPlaceholder("work@email.com")).toBeVisible(); + expect(await horizontalOverflow(page), `expanded login at ${width}px`).toBe(0); + } + }); + + test("the signup form is labelled and operable at 375px", async ({ page }) => { + // A distinct address: the serial block above already registers mockUsers.signup[0]. + const mobileEmail = `signup-mobile-${Date.now()}@formbricks.com`; + + await page.setViewportSize(MOBILE); + await page.goto("/auth/signup"); + await page.getByText("Continue with Email").click(); + + await page.getByTestId("signup-name").fill(name); + await page.getByTestId("signup-email").fill(mobileEmail); + await page.getByTestId("signup-password").fill(password); + + // axe's `label` rule (wcag2a) is what holds the labels to account — it fails on any + // input without an accessible name, without pinning the copy to a string in this spec. + + const results = await new AxeBuilder({ page }).withTags(WCAG_AA_TAGS).analyze(); + const summary = results.violations.map((v) => `${v.id} (${v.nodes.length})`).join(", "); + expect(results.violations, `axe AA violations: ${summary}`).toEqual([]); + + await page.getByTestId("signup-submit").click(); + await page.waitForURL(/\/auth\/signup-without-verification-success.*/); + }); +}); diff --git a/apps/web/playwright/utils/helper.ts b/apps/web/playwright/utils/helper.ts index 71a643a0ac44..c1cf7ccc376c 100644 --- a/apps/web/playwright/utils/helper.ts +++ b/apps/web/playwright/utils/helper.ts @@ -441,15 +441,15 @@ export const signupUsingInviteToken = async (page: Page, name: string, email: st const RENDER_SETTLE_MS = 150; const flushRender = async (page: Page): Promise => { - await page.evaluate( - (settleMs) => - new Promise((resolve) => { - setTimeout(() => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - }, settleMs); - }), - RENDER_SETTLE_MS - ); + await page.evaluate(async (settleMs) => { + // Sequential awaits rather than nested callbacks: the callback form stacked six functions deep, + // and the order it encoded — sleep, then two frames — is what the doc comment above requires. + const nextFrame = (): Promise => new Promise((resolve) => requestAnimationFrame(resolve)); + + await new Promise((resolve) => setTimeout(resolve, settleMs)); + await nextFrame(); + await nextFrame(); + }, RENDER_SETTLE_MS); }; /** diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 4887635ca4f7..03669de1eb9e 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -117,6 +117,16 @@ export const config = { matcher: [ // Keep asset exclusions segment-bound: every dynamic route must traverse Proxy so callers cannot // bypass the private client-IP header overwrite by choosing an asset-like route prefix. - "/((?!_next/(?:static|image)(?:/|$)|(?:favicon\\.ico|sitemap\\.xml|robots\\.txt)$|(?:js|css|images|fonts|icons|public|animated-bgs)(?:/|$)).*)", + // + // The `\\.` escapes stay, and S7780's `String.raw` fix must NOT be applied here: Next.js reads this + // matcher by statically parsing the file, and its extractor + // (`next/dist/build/analysis/extract-const-value.js`, 16.2.11) understands string literals, + // template literals, arrays and objects — but not a `TaggedTemplateExpression`. Verified against + // Next's own SWC parser: `String.raw` yields `Unsupported node type "TaggedTemplateExpression" at + // "config.matcher[0]"`, which throws in dev and, in a production build, only logs an error and + // leaves `config` undefined — so `parseMiddlewareConfig` receives nothing and the matcher is + // silently DROPPED, running Proxy on every static asset and voiding the exclusions above. A plain + // template literal parses, but needs the same `\\.` escapes, so it does not satisfy the rule either. + "/((?!_next/(?:static|image)(?:/|$)|(?:favicon\\.ico|sitemap\\.xml|robots\\.txt)$|(?:js|css|images|fonts|icons|public|animated-bgs)(?:/|$)).*)", // NOSONAR(typescript:S7780) -- String.raw breaks Next.js's static matcher extraction; see above ], }; diff --git a/packages/survey-ui/package.json b/packages/survey-ui/package.json index a07c075c4a19..0f49b9b2a983 100644 --- a/packages/survey-ui/package.json +++ b/packages/survey-ui/package.json @@ -48,7 +48,11 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, - "./styles": "./dist/survey-ui.css" + "./styles": "./dist/survey-ui.css", + "./youtube-id": { + "types": "./dist/lib/youtube-id.d.ts", + "import": "./dist/lib/youtube-id.js" + } }, "scripts": { "dev": "vite build --watch --mode dev", diff --git a/packages/survey-ui/src/lib/video.test.ts b/packages/survey-ui/src/lib/video.test.ts index 27381e5df626..bb4ff5f3eef3 100644 --- a/packages/survey-ui/src/lib/video.test.ts +++ b/packages/survey-ui/src/lib/video.test.ts @@ -204,3 +204,34 @@ describe("isSafeMediaUrl", () => { } ); }); + +describe("extractYoutubeId — stored-value denial of service (ENG-2789)", () => { + // The pattern list this replaced used `youtube\\.com.*v=(…)`, whose `.*` backtracks once per + // `youtube.com`. `ZStorageUrl` is an unbounded `z.string()`, so a value this long persists and + // reaches the RESPONDENT renderer, where `element-media.tsx` converts it twice per render. + test("a long repeated-host URL resolves fast instead of blocking the thread", () => { + const stored = `https://youtube.com/${"youtube.com/".repeat(25_600)}`; // 307,220 characters + + const startedAt = performance.now(); + const result = convertToEmbedUrl(stored); + const elapsedMs = performance.now() - startedAt; + + expect(result).toBeUndefined(); + // Budget chosen from measurements, not a round number: the scan costs 6ms uninstrumented but + // ~330ms under the coverage run CI uses, which slows tight character loops far more than it + // slows a native regex. The pattern this replaced takes ~3300ms on the same input either way, + // so 2000ms sits above the instrumented pass and below the regression it guards against. + expect(elapsedMs).toBeLessThan(2000); + }); + + // Greedy `.*` took the LAST marker on the line and backtracked to an earlier one when the last + // had no id after it. Both are load-bearing, so they are pinned rather than left to the corpus. + test.each([ + ["https://www.youtube.com/watch?x=v=FIRST&v=SECOND", "https://www.youtube.com/embed/SECOND"], + ["https://youtube.com/embed/abc/embed/def", "https://www.youtube.com/embed/def"], + ["https://youtube.com/watch?v=&v=OK", "https://www.youtube.com/embed/OK"], + ["https://youtube.com/watch?v=&v=", undefined], + ])("resolves %s to %s, as the pattern did", (url, expected) => { + expect(convertToEmbedUrl(url)).toBe(expected); + }); +}); diff --git a/packages/survey-ui/src/lib/video.ts b/packages/survey-ui/src/lib/video.ts index c9dce8e43792..6370ad5f21c9 100644 --- a/packages/survey-ui/src/lib/video.ts +++ b/packages/survey-ui/src/lib/video.ts @@ -1,3 +1,5 @@ +import { extractYoutubeId } from "./youtube-id"; + export const checkForYoutubeUrl = (url: string): boolean => { try { const youtubeUrl = new URL(url); @@ -53,29 +55,6 @@ export const checkForLoomUrl = (url: string): boolean => { } }; -const extractYoutubeId = (url: string): string | null => { - let id = ""; - - // Regular expressions for various YouTube URL formats - const regExpList = [ - /youtu\.be\/(?[a-zA-Z0-9_-]+)/, // youtu.be/ - /youtube\.com.*v=(?[a-zA-Z0-9_-]+)/, // youtube.com/watch?v= - /youtube\.com.*embed\/(?[a-zA-Z0-9_-]+)/, // youtube.com/embed/ - /youtube-nocookie\.com\/embed\/(?[a-zA-Z0-9_-]+)/, // youtube-nocookie.com/embed/ - ]; - - regExpList.some((regExp) => { - const match = regExp.exec(url); - if (match?.groups?.videoId) { - id = match.groups.videoId; - return true; - } - return false; - }); - - return id || null; -}; - const extractVimeoId = (url: string): string | null => { const regExp = /vimeo\.com\/(?:video\/)?(?\d+)/; const match = regExp.exec(url); diff --git a/packages/survey-ui/src/lib/youtube-id.ts b/packages/survey-ui/src/lib/youtube-id.ts new file mode 100644 index 000000000000..dcf7d8c1a414 --- /dev/null +++ b/packages/survey-ui/src/lib/youtube-id.ts @@ -0,0 +1,94 @@ +/** + * Shared by all three copies of the video-URL helpers — this package, `@formbricks/surveys`, and + * `apps/web`. Those three modules are deliberately parallel (three build targets), but this scan is + * subtle enough that three copies would be three chances to get the greedy/backtracking/line + * semantics wrong, so it lives here and they import it. + */ + +// `youtube.com` followed later by `v=` or `embed/`, scanned rather than matched with +// `/youtube\.com.*v=(…)/`. That pattern is quadratic: `.*` runs to the end of the line and +// backtracks once per `youtube.com`, so a stored value of `"youtube.com/".repeat(25_600)` took +// 6.7s per call — and `element-media.tsx` calls the converter twice, blocking the RESPONDENT's +// main thread for ~13s. `ZStorageUrl` is an unbounded `z.string()`, so nothing caps the value on +// its way into the database (ENG-2789). +// +// A length cap was rejected: it would resolve a different id than the pattern did when a marker +// sits beyond it. This reproduces the pattern exactly instead. +// +// The equivalence rests on three properties of the original: +// - `.` excludes line terminators, so the marker shares a line with the host it follows. +// - `.*` is greedy, so the LAST marker on that line wins, and it backtracks to an earlier one +// when the last has no id character after it. +// - If the first host on a line has no usable marker, no later host on that line can either — +// its search region is a suffix of the first's — so the scan skips the line instead of +// retrying every position, which is what makes it linear. +const YOUTUBE_HOST = "youtube.com"; + +const isYoutubeIdCharacter = (character: string | undefined): boolean => + character !== undefined && /[a-zA-Z0-9_-]/.test(character); + +const isLineTerminator = (character: string): boolean => + character === "\n" || character === "\r" || character === "\u2028" || character === "\u2029"; + +/** Greedy run of id characters at `start`, or "" when there is none. */ +const readYoutubeId = (url: string, start: number): string => { + let end = start; + while (end < url.length && isYoutubeIdCharacter(url[end])) end++; + return url.slice(start, end); +}; + +/** + * The id the pattern `youtube\.com.*([a-zA-Z0-9_-]+)` would capture, or "" when it would + * not match. + */ +export const extractIdAfterHostMarker = (url: string, marker: string): string => { + let searchFrom = 0; + + while (searchFrom <= url.length) { + const host = url.indexOf(YOUTUBE_HOST, searchFrom); + if (host === -1) return ""; + + const regionStart = host + YOUTUBE_HOST.length; + let lineEnd = regionStart; + while (lineEnd < url.length && !isLineTerminator(url[lineEnd])) lineEnd++; + + // Greedy `.*` takes the last usable marker on the line, falling back to earlier ones when the + // id run after it is empty. + for (let at = lineEnd - marker.length; at >= regionStart; at--) { + if (!url.startsWith(marker, at)) continue; + const id = readYoutubeId(url, at + marker.length); + if (id) return id; + } + + searchFrom = lineEnd + 1; + } + + return ""; +}; + +/** + * The YouTube video id in `url`, or null. + * + * Order is preserved from the pattern list this replaces: youtu.be, then `v=`, then `embed/`, then + * youtube-nocookie. The first and last carry no `.*`, so they stay regexes; the middle two are the + * quadratic ones and go through the scan above. + */ +export const extractYoutubeId = (url: string): string | null => { + for (const [pattern, marker] of [ + [/youtu\.be\/([a-zA-Z0-9_-]+)/, null], + [null, "v="], + [null, "embed/"], + [/youtube-nocookie\.com\/embed\/([a-zA-Z0-9_-]+)/, null], + ] as [RegExp | null, string | null][]) { + if (pattern) { + const match = pattern.exec(url); + if (match?.[1]) return match[1]; + continue; + } + + const id = extractIdAfterHostMarker(url, marker as string); + if (id) return id; + } + + return null; +}; diff --git a/packages/surveys/src/lib/recall.ts b/packages/surveys/src/lib/recall.ts index 1dac6d5c90c1..33aa2eb99070 100644 --- a/packages/surveys/src/lib/recall.ts +++ b/packages/surveys/src/lib/recall.ts @@ -11,10 +11,19 @@ const extractId = (text: string): string | null => { }; // Extracts the fallback value from a string containing the "fallback" pattern. +// An index scan, not `/fallback:([^#]*)#/`: that pattern is O(N^2) on a long run of `fallback:` +// with no `#` after it, because the engine rescans to the end from every occurrence. Identical +// result — `[^#]*` cannot cross a `#`, so the regex ends at the first `#` after the FIRST +// `fallback:`, and if none follows that one none follows a later one either. +const FALLBACK_MARKER = "fallback:"; + const extractFallbackValue = (text: string): string => { - const pattern = /fallback:([^#]*)#/; - const match = text.match(pattern); - return match?.[1] ?? ""; + const markerStart = text.indexOf(FALLBACK_MARKER); + if (markerStart === -1) return ""; + + const valueStart = markerStart + FALLBACK_MARKER.length; + const valueEnd = text.indexOf("#", valueStart); + return valueEnd === -1 ? "" : text.slice(valueStart, valueEnd); }; // Extracts the complete recall information (ID and fallback) from a headline string. diff --git a/packages/surveys/src/lib/video-upload.test.ts b/packages/surveys/src/lib/video-upload.test.ts index b1efb426db34..ae430ea23258 100644 --- a/packages/surveys/src/lib/video-upload.test.ts +++ b/packages/surveys/src/lib/video-upload.test.ts @@ -116,3 +116,37 @@ describe("convertToEmbedUrl", () => { }); }); }); + +describe("extractYoutubeId — stored-value denial of service (ENG-2789)", () => { + // The pattern list this replaced used `youtube\\.com.*v=(…)`, whose `.*` backtracks once per + // `youtube.com`. `ZStorageUrl` is an unbounded `z.string()`, so a value this long persists and + // reaches the RESPONDENT renderer, where `element-media.tsx` converts it twice per render. + test("a long repeated-host URL resolves fast instead of blocking the thread", () => { + const stored = `https://youtube.com/${"youtube.com/".repeat(25_600)}`; // 307,220 characters + + const startedAt = performance.now(); + const result = extractYoutubeId(stored); + const elapsedMs = performance.now() - startedAt; + + expect(result).toBeNull(); + // Budget chosen from measurements, not a round number: the scan costs 6ms uninstrumented but + // ~330ms under the coverage run CI uses, which slows tight character loops far more than it + // slows a native regex. The pattern this replaced takes ~3300ms on the same input either way, + // so 2000ms sits above the instrumented pass and below the regression it guards against. + expect(elapsedMs).toBeLessThan(2000); + }); + + // Greedy `.*` took the LAST marker on the line and backtracked to an earlier one when the last + // had no id after it. Both are load-bearing, so they are pinned rather than left to the corpus. + test.each([ + ["https://www.youtube.com/watch?x=v=FIRST&v=SECOND", "SECOND"], + ["https://youtube.com/embed/abc/embed/def", "def"], + ["https://youtube.com/watch?v=&v=OK", "OK"], + ["https://youtube.com/watch?v=&v=", null], + // `.` cannot cross a line terminator, so a marker on the next line is not reachable. + ["youtube.com\nv=NEXTLINE", null], + ["youtube.com v=SAMELINE\nyoutube.com v=SECOND", "SAMELINE"], + ])("resolves %s to %s, as the pattern did", (url, expected) => { + expect(extractYoutubeId(url)).toBe(expected); + }); +}); diff --git a/packages/surveys/src/lib/video-upload.ts b/packages/surveys/src/lib/video-upload.ts index 18bbfb816d77..e558a08e40cb 100644 --- a/packages/surveys/src/lib/video-upload.ts +++ b/packages/surveys/src/lib/video-upload.ts @@ -1,3 +1,7 @@ +import { extractYoutubeId } from "@formbricks/survey-ui/youtube-id"; + +export { extractYoutubeId }; + export const checkForYoutubeUrl = (url: string): boolean => { try { const youtubeUrl = new URL(url); @@ -53,29 +57,6 @@ export const checkForLoomUrl = (url: string): boolean => { } }; -export const extractYoutubeId = (url: string): string | null => { - let id = ""; - - // Regular expressions for various YouTube URL formats - const regExpList = [ - /youtu\.be\/([a-zA-Z0-9_-]+)/, // youtu.be/ - /youtube\.com.*v=([a-zA-Z0-9_-]+)/, // youtube.com/watch?v= - /youtube\.com.*embed\/([a-zA-Z0-9_-]+)/, // youtube.com/embed/ - /youtube-nocookie\.com\/embed\/([a-zA-Z0-9_-]+)/, // youtube-nocookie.com/embed/ - ]; - - regExpList.some((regExp) => { - const match = url.match(regExp); - if (match && match[1]) { - id = match[1]; - return true; - } - return false; - }); - - return id || null; -}; - const extractVimeoId = (url: string): string | null => { const regExp = /vimeo\.com\/(?:video\/)?(\d+)/; const match = url.match(regExp); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2737977e7345..0305dfda743f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -410,6 +410,9 @@ importers: '@formbricks/storage': specifier: workspace:* version: link:../../packages/storage + '@formbricks/survey-ui': + specifier: workspace:* + version: link:../../packages/survey-ui '@formbricks/surveys': specifier: workspace:* version: link:../../packages/surveys diff --git a/sonar-project.properties b/sonar-project.properties index 69b311f4e779..ba47876c5559 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,7 +3,14 @@ sonar.organization=formbricks # Sources sonar.sources=apps/web,packages/surveys,packages/survey-ui,packages/js-core,packages/cache,packages/storage -sonar.exclusions=**/node_modules/**,**/.next/**,**/dist/**,**/build/**,**/*.test.*,**/*.spec.*,**/__mocks__/**,packages/survey-ui/**/*.stories.* +# `**/.env` is a generated file, not source. `apps/web/.env` is a tracked SYMLINK to the repo-root +# `.env` (which is gitignored) so that Next.js picks the root file up, and CI runs `pnpm dev:setup` +# before scanning — which writes that file from `.env.example` and fills every secret with a fresh +# `openssl rand -hex 32` (scripts/setup-dev-env.sh). Sonar then followed the symlink and reported +# those throwaway runner-local values as hardcoded credentials. Nothing is committed: the template +# ships the keys empty. `.env.example` itself stays in scope, so a real secret added there is still +# caught. +sonar.exclusions=**/node_modules/**,**/.next/**,**/dist/**,**/build/**,**/*.test.*,**/*.spec.*,**/__mocks__/**,packages/survey-ui/**/*.stories.*,**/.env # Tests sonar.tests=apps/web,packages/surveys,packages/survey-ui,packages/js-core,packages/cache,packages/storage