Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions apps/web/app/api/v1/client/[environmentId]/responses/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
Expand Down Expand Up @@ -127,6 +129,114 @@ export const POST = withV1ApiWrapper({
};
}

if (survey.type === "link" && survey.singleUse?.enabled) {
if (!responseInputData.singleUseId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}

if (!responseInputData.meta?.url) {
return {
response: responses.badRequestResponse(
"Missing or invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}

let url: URL;
try {
url = new URL(responseInputData.meta.url);
} catch (error) {
return {
response: responses.badRequestResponse(
"Invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
error: error instanceof Error ? error.message : "Unknown error occurred",
},
true
),
};
}

const suId = url.searchParams.get("suId");
if (!suId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}

if (survey.singleUse.isEncrypted) {
if (!ENCRYPTION_KEY) {
logger.error({ url: req.url, surveyId: survey.id, environmentId }, "ENCRYPTION_KEY is not set");
return {
response: responses.internalServerErrorResponse("An unexpected error occurred.", true),
};
}

let decryptedSuId: string;
try {
decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
} catch {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}

if (decryptedSuId !== responseInputData.singleUseId) {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
} else if (responseInputData.singleUseId !== suId) {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
}

if (!validateFileUploads(responseInputData.data, survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
Expand Down
73 changes: 72 additions & 1 deletion packages/surveys/postcss.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,77 @@ const stripLayerProperties = () => {
};
stripLayerProperties.postcss = true;

// Re-scopes `:root` and `:host` selectors inside `@layer theme` to `#fbjs`.
//
// Problem: Tailwind v4 emits `@layer theme { :root, :host { --color-*; --spacing; ... } }`
// which sets CSS custom properties globally. When the survey widget injects this
// into the host page, it overrides the host site's own Tailwind theme variables,
// causing buttons and other styled elements to change color/appearance.
//
// Fix: Replace `:root` / `:host` selectors with `#fbjs` so theme variables are
// scoped to the survey widget. CSS custom properties inherit, so all survey
// descendants still pick them up.
const scopeLayerTheme = () => {
return {
postcssPlugin: "postcss-scope-layer-theme",
AtRule: {
layer: (atRule) => {
if (atRule.params !== "theme") return;
atRule.walkRules((rule) => {
rule.selectors = rule.selectors.map((sel) => {
if (sel === ":root" || sel === ":host") return "#fbjs";
return sel;
});
});
},
},
};
};
scopeLayerTheme.postcss = true;

// Replaces global `@property --tw-*` declarations with a scoped `#fbjs` rule
// that provides the same initial values.
//
// Problem: `@property` is globally scoped by spec and cannot be confined to
// `#fbjs`. Tailwind v4 emits declarations like:
// @property --tw-gradient-to-position { syntax: "<length-percentage>"; inherits: false; initial-value: 100% }
//
// `inherits: false` breaks inheritance of `--tw-*` custom properties on the
// host page, and the `syntax` constraint rejects host-page values that don't
// match. Both cause host-page Tailwind v3 utilities to lose their styling.
//
// Fix: Collect each property's initial-value, remove the `@property` rule,
// then emit a single `#fbjs, #fbjs *, #fbjs ::before, #fbjs ::after` rule
// with the initial values. This gives the survey widget the defaults it needs
// while keeping everything scoped.
const replaceAtPropertyWithScoped = () => {
return {
postcssPlugin: "postcss-replace-at-property-with-scoped",
OnceExit(root) {
const collected = new Map();
root.walkAtRules("property", (atRule) => {
if (!atRule.params.startsWith("--tw-")) return;
const name = atRule.params;
atRule.walkDecls("initial-value", (decl) => {
collected.set(name, decl.value);
});
atRule.remove();
});

if (collected.size === 0) return;
const postcss = require("postcss");
const rule = postcss.rule({
selectors: ["#fbjs", "#fbjs *", "#fbjs ::before", "#fbjs ::after", "#fbjs ::backdrop"],
});
for (const [name, value] of collected) {
rule.append(postcss.decl({ prop: name, value }));
}
root.append(rule);
},
};
};
replaceAtPropertyWithScoped.postcss = true;

module.exports = {
plugins: [require("@tailwindcss/postcss"), require("autoprefixer"), remtoEm(), stripLayerProperties()],
plugins: [require("@tailwindcss/postcss"), require("autoprefixer"), remtoEm(), stripLayerProperties(), scopeLayerTheme(), replaceAtPropertyWithScoped()],
};
Loading