feat: adds second domain (#4989)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Piyush Gupta
2025-03-28 22:52:17 +05:30
committed by GitHub
parent c70008d1be
commit 5c583028e0
37 changed files with 205 additions and 189 deletions
+3
View File
@@ -80,6 +80,9 @@ S3_ENDPOINT_URL=
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
S3_FORCE_PATH_STYLE=0
# Set this URL to add a custom domain to your survey links(default is WEBAPP_URL)
# SURVEY_URL=https://survey.example.com
#####################
# Disable Features #
#####################
@@ -13,6 +13,7 @@ import {
RESPONSES_PER_PAGE,
WEBAPP_URL,
} from "@formbricks/lib/constants";
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
@@ -47,6 +48,7 @@ const Page = async (props) => {
});
const shouldGenerateInsights = needsInsightsGeneration(survey);
const locale = await findMatchingLocale();
const surveyDomain = getSurveyDomain();
return (
<PageContentWrapper>
@@ -57,8 +59,8 @@ const Page = async (props) => {
environment={environment}
survey={survey}
isReadOnly={isReadOnly}
webAppUrl={WEBAPP_URL}
user={user}
surveyDomain={surveyDomain}
/>
}>
{isAIEnabled && shouldGenerateInsights && (
@@ -23,19 +23,19 @@ import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
interface ShareEmbedSurveyProps {
survey: TSurvey;
surveyDomain: string;
open: boolean;
modalView: "start" | "embed" | "panel";
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
webAppUrl: string;
user: TUser;
}
export const ShareEmbedSurvey = ({
survey,
surveyDomain,
open,
modalView,
setOpen,
webAppUrl,
user,
}: ShareEmbedSurveyProps) => {
const router = useRouter();
@@ -104,8 +104,8 @@ export const ShareEmbedSurvey = ({
<DialogDescription className="hidden" />
<ShareSurveyLink
survey={survey}
webAppUrl={webAppUrl}
surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>
@@ -159,8 +159,8 @@ export const ShareEmbedSurvey = ({
survey={survey}
email={email}
surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl}
webAppUrl={webAppUrl}
locale={user.locale}
/>
) : showView === "panel" ? (
@@ -20,8 +20,8 @@ interface SurveyAnalysisCTAProps {
survey: TSurvey;
environment: TEnvironment;
isReadOnly: boolean;
webAppUrl: string;
user: TUser;
surveyDomain: string;
}
interface ModalState {
@@ -35,8 +35,8 @@ export const SurveyAnalysisCTA = ({
survey,
environment,
isReadOnly,
webAppUrl,
user,
surveyDomain,
}: SurveyAnalysisCTAProps) => {
const { t } = useTranslate();
const searchParams = useSearchParams();
@@ -50,7 +50,7 @@ export const SurveyAnalysisCTA = ({
dropdown: false,
});
const surveyUrl = useMemo(() => `${webAppUrl}/s/${survey.id}`, [survey.id, webAppUrl]);
const surveyUrl = useMemo(() => `${surveyDomain}/s/${survey.id}`, [survey.id, surveyDomain]);
const { refreshSingleUseId } = useSingleUseId(survey);
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -172,9 +172,9 @@ export const SurveyAnalysisCTA = ({
<ShareEmbedSurvey
key={key}
survey={survey}
surveyDomain={surveyDomain}
open={modalState[key as keyof ModalState]}
setOpen={setOpen}
webAppUrl={webAppUrl}
user={user}
modalView={modalView}
/>
@@ -20,8 +20,8 @@ interface EmbedViewProps {
survey: any;
email: string;
surveyUrl: string;
surveyDomain: string;
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
webAppUrl: string;
locale: TUserLocale;
}
@@ -35,8 +35,8 @@ export const EmbedView = ({
survey,
email,
surveyUrl,
surveyDomain,
setSurveyUrl,
webAppUrl,
locale,
}: EmbedViewProps) => {
const { t } = useTranslate();
@@ -82,8 +82,8 @@ export const EmbedView = ({
) : activeId === "link" ? (
<LinkTab
survey={survey}
webAppUrl={webAppUrl}
surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
@@ -8,13 +8,13 @@ import { TUserLocale } from "@formbricks/types/user";
interface LinkTabProps {
survey: TSurvey;
webAppUrl: string;
surveyUrl: string;
surveyDomain: string;
setSurveyUrl: (url: string) => void;
locale: TUserLocale;
}
export const LinkTab = ({ survey, webAppUrl, surveyUrl, setSurveyUrl, locale }: LinkTabProps) => {
export const LinkTab = ({ survey, surveyUrl, surveyDomain, setSurveyUrl, locale }: LinkTabProps) => {
const { t } = useTranslate();
const docsLinks = [
@@ -43,8 +43,8 @@ export const LinkTab = ({ survey, webAppUrl, surveyUrl, setSurveyUrl, locale }:
</p>
<ShareSurveyLink
survey={survey}
webAppUrl={webAppUrl}
surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
@@ -78,7 +78,7 @@ const dummySurvey = {
} as unknown as TSurvey;
const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment;
const dummyUser = { id: "user123", name: "Test User" } as TUser;
const webAppUrl = "http://example.com";
const surveyDomain = "https://surveys.test.formbricks.com";
describe("SurveyAnalysisCTA - handleCopyLink", () => {
afterEach(() => {
@@ -91,7 +91,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
webAppUrl={webAppUrl}
surveyDomain={surveyDomain}
user={dummyUser}
/>
);
@@ -101,7 +101,9 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
await waitFor(() => {
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
expect(writeTextMock).toHaveBeenCalledWith("http://example.com/s/survey123?id=newSingleUseId");
expect(writeTextMock).toHaveBeenCalledWith(
"https://surveys.test.formbricks.com/s/survey123?id=newSingleUseId"
);
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
});
@@ -113,7 +115,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
webAppUrl={webAppUrl}
surveyDomain={surveyDomain}
user={dummyUser}
/>
);
@@ -1,6 +1,6 @@
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
import { getTranslate } from "@/tolgee/server";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getStyling } from "@formbricks/lib/utils/styling";
@@ -17,7 +17,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
}
const styling = getStyling(project, survey);
const surveyUrl = WEBAPP_URL + "/s/" + survey.id;
const surveyUrl = getSurveyDomain() + "/s/" + survey.id;
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
@@ -16,6 +16,7 @@ import {
MAX_RESPONSES_FOR_INSIGHT_GENERATION,
WEBAPP_URL,
} from "@formbricks/lib/constants";
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getUser } from "@formbricks/lib/user/service";
@@ -54,6 +55,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
billing: organization.billing,
});
const shouldGenerateInsights = needsInsightsGeneration(survey);
const surveyDomain = getSurveyDomain();
return (
<PageContentWrapper>
@@ -64,8 +66,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
environment={environment}
survey={survey}
isReadOnly={isReadOnly}
webAppUrl={WEBAPP_URL}
user={user}
surveyDomain={surveyDomain}
/>
}>
{isAIEnabled && shouldGenerateInsights && (
@@ -1,51 +0,0 @@
import { responses } from "@/app/lib/api/response";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { AsyncParser } from "@json2csv/node";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
export const POST = async (request: NextRequest) => {
const session = await getServerSession(authOptions);
if (!session) {
return responses.unauthorizedResponse();
}
const data = await request.json();
let csv: string = "";
const { json, fields, fileName } = data;
const fallbackFileName = fileName.replace(/[^A-Za-z0-9_.-]/g, "_");
const encodedFileName = encodeURIComponent(fileName)
.replace(/['()]/g, (match) => "%" + match.charCodeAt(0).toString(16))
.replace(/\*/g, "%2A");
const parser = new AsyncParser({
fields,
});
try {
csv = await parser.parse(json).promise();
} catch (err) {
logger.error({ error: err, url: request.url }, "Failed to convert to CSV");
throw new Error("Failed to convert to CSV");
}
const headers = new Headers();
headers.set("Content-Type", "text/csv;charset=utf-8;");
headers.set(
"Content-Disposition",
`attachment; filename="${fallbackFileName}"; filename*=UTF-8''${encodedFileName}`
);
return Response.json(
{
fileResponse: csv,
},
{
headers,
}
);
};
@@ -1,46 +0,0 @@
import { responses } from "@/app/lib/api/response";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import * as xlsx from "xlsx";
export const POST = async (request: NextRequest) => {
const session = await getServerSession(authOptions);
if (!session) {
return responses.unauthorizedResponse();
}
const data = await request.json();
const { json, fields, fileName } = data;
const fallbackFileName = fileName.replace(/[^A-Za-z0-9_.-]/g, "_");
const encodedFileName = encodeURIComponent(fileName)
.replace(/['()]/g, (match) => "%" + match.charCodeAt(0).toString(16))
.replace(/\*/g, "%2A");
const wb = xlsx.utils.book_new();
const ws = xlsx.utils.json_to_sheet(json, { header: fields });
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
const buffer = xlsx.write(wb, { type: "buffer", bookType: "xlsx" }) as Buffer;
const base64String = buffer.toString("base64");
const headers = new Headers();
headers.set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
headers.set(
"Content-Disposition",
`attachment; filename="${fallbackFileName}"; filename*=UTF-8''${encodedFileName}`
);
return Response.json(
{
fileResponse: base64String,
},
{
headers,
}
);
};
@@ -1,6 +1,7 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { NextRequest } from "next/server";
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { getSurvey } from "@formbricks/lib/survey/service";
import { generateSurveySingleUseIds } from "@formbricks/lib/utils/singleUseSurveys";
@@ -36,9 +37,10 @@ export const GET = async (
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
const surveyDomain = getSurveyDomain();
// map single use ids to survey links
const surveyLinks = singleUseIds.map(
(singleUseId) => `${process.env.WEBAPP_URL}/s/${survey.id}?suId=${singleUseId}`
(singleUseId) => `${surveyDomain}/s/${survey.id}?suId=${singleUseId}`
);
return responses.successResponse(surveyLinks);
-18
View File
@@ -1,18 +0,0 @@
export const fetchFile = async (
data: { json: any; fields?: string[]; fileName?: string },
filetype: string
) => {
const endpoint = filetype === "csv" ? "csv-conversion" : "excel-conversion";
const response = await fetch(`/api/${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) throw new Error("Failed to convert to file");
return response.json();
};
+37 -16
View File
@@ -24,8 +24,15 @@ import { ipAddress } from "@vercel/functions";
import { getToken } from "next-auth/jwt";
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { E2E_TESTING, IS_PRODUCTION, RATE_LIMITING_DISABLED, WEBAPP_URL } from "@formbricks/lib/constants";
import {
E2E_TESTING,
IS_PRODUCTION,
RATE_LIMITING_DISABLED,
SURVEY_URL,
WEBAPP_URL,
} from "@formbricks/lib/constants";
import { isValidCallbackUrl } from "@formbricks/lib/utils/url";
import { logger } from "@formbricks/logger";
const enforceHttps = (request: NextRequest): Response | null => {
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "http";
@@ -78,7 +85,34 @@ const applyRateLimiting = (request: NextRequest, ip: string) => {
}
};
const handleSurveyDomain = (request: NextRequest): Response | null => {
try {
if (!SURVEY_URL) return null;
const host = request.headers.get("host") || "";
const surveyDomain = SURVEY_URL ? new URL(SURVEY_URL).host : "";
if (host !== surveyDomain) return null;
return new NextResponse(null, { status: 404 });
} catch (error) {
logger.error(error, "Error handling survey domain");
return new NextResponse(null, { status: 404 });
}
};
const isSurveyRoute = (request: NextRequest) => {
return request.nextUrl.pathname.startsWith("/c/") || request.nextUrl.pathname.startsWith("/s/");
};
export const middleware = async (originalRequest: NextRequest) => {
if (isSurveyRoute(originalRequest)) {
return NextResponse.next();
}
// Handle survey domain routing.
const surveyResponse = handleSurveyDomain(originalRequest);
if (surveyResponse) return surveyResponse;
// Create a new Request object to override headers and add a unique request ID header
const request = new NextRequest(originalRequest, {
headers: new Headers(originalRequest.headers),
@@ -88,6 +122,7 @@ export const middleware = async (originalRequest: NextRequest) => {
request.headers.set("x-start-time", Date.now().toString());
// Create a new NextResponse object to forward the new request with headers
const nextResponseWithCustomHeader = NextResponse.next({
request: {
headers: request.headers,
@@ -132,20 +167,6 @@ export const middleware = async (originalRequest: NextRequest) => {
export const config = {
matcher: [
"/api/auth/callback/credentials",
"/api/(.*)/client/:path*",
"/api/v1/js/actions",
"/api/v1/client/storage",
"/share/(.*)/:path",
"/environments/:path*",
"/setup/organization/:path*",
"/api/auth/signout",
"/auth/login",
"/auth/signup",
"/api/packages/:path*",
"/auth/verification-requested",
"/auth/forgot-password",
"/api/v1/management/:path*",
"/api/v2/management/:path*",
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|js|css|images|fonts|icons|public|api/v1/og).*)", // Exclude the Open Graph image generation route from middleware
],
};
@@ -15,7 +15,7 @@ import { SurveyLinkDisplay } from "./components/SurveyLinkDisplay";
interface ShareSurveyLinkProps {
survey: TSurvey;
webAppUrl: string;
surveyDomain: string;
surveyUrl: string;
setSurveyUrl: (url: string) => void;
locale: TUserLocale;
@@ -23,8 +23,8 @@ interface ShareSurveyLinkProps {
export const ShareSurveyLink = ({
survey,
webAppUrl,
surveyUrl,
surveyDomain,
setSurveyUrl,
locale,
}: ShareSurveyLinkProps) => {
@@ -32,7 +32,7 @@ export const ShareSurveyLink = ({
const [language, setLanguage] = useState("default");
const getUrl = useCallback(async () => {
let url = `${webAppUrl}/s/${survey.id}`;
let url = `${surveyDomain}/s/${survey.id}`;
const queryParams: string[] = [];
if (survey.singleUse?.enabled) {
@@ -58,7 +58,9 @@ export const ShareSurveyLink = ({
}
setSurveyUrl(url);
}, [survey, webAppUrl, language]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [survey, surveyDomain, language]);
const generateNewSingleUseLink = () => {
getUrl();
@@ -1,6 +1,6 @@
import jwt from "jsonwebtoken";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ENCRYPTION_KEY, WEBAPP_URL } from "@formbricks/lib/constants";
import { ENCRYPTION_KEY, SURVEY_URL } from "@formbricks/lib/constants";
import * as crypto from "@formbricks/lib/crypto";
import * as contactSurveyLink from "./contact-survey-link";
@@ -15,7 +15,7 @@ vi.mock("jsonwebtoken", () => ({
// Mock constants - MUST be a literal object without using variables
vi.mock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!",
WEBAPP_URL: "https://test.formbricks.com",
SURVEY_URL: "https://test.formbricks.com",
}));
vi.mock("@formbricks/lib/crypto", () => ({
@@ -73,7 +73,7 @@ describe("Contact Survey Link", () => {
// Verify the returned URL
expect(result).toEqual({
ok: true,
data: `${WEBAPP_URL}/c/${mockToken}`,
data: `${SURVEY_URL}/c/${mockToken}`,
});
});
@@ -98,7 +98,7 @@ describe("Contact Survey Link", () => {
// Remock constants to simulate missing ENCRYPTION_KEY
vi.doMock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: undefined,
WEBAPP_URL: "https://test.formbricks.com",
SURVEY_URL: "https://test.formbricks.com",
}));
// Reimport the modules so they pick up the new mock
const { getContactSurveyLink } = await import("./contact-survey-link");
@@ -172,7 +172,7 @@ describe("Contact Survey Link", () => {
vi.resetModules();
vi.doMock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: undefined,
WEBAPP_URL: "https://test.formbricks.com",
SURVEY_URL: "https://test.formbricks.com",
}));
const { verifyContactSurveyToken } = await import("./contact-survey-link");
const result = verifyContactSurveyToken(mockToken);
@@ -1,7 +1,8 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import jwt from "jsonwebtoken";
import { ENCRYPTION_KEY, WEBAPP_URL } from "@formbricks/lib/constants";
import { ENCRYPTION_KEY } from "@formbricks/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@formbricks/lib/crypto";
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { Result, err, ok } from "@formbricks/types/error-handlers";
// Creates an encrypted personalized survey link for a contact
@@ -41,7 +42,7 @@ export const getContactSurveyLink = (
const token = jwt.sign(payload, ENCRYPTION_KEY, tokenOptions);
// Return the personalized URL
return ok(`${WEBAPP_URL}/c/${token}`);
return ok(`${getSurveyDomain()}/c/${token}`);
};
// Validates and decrypts a contact survey JWT token
+3 -2
View File
@@ -16,6 +16,7 @@ import {
SMTP_USER,
WEBAPP_URL,
} from "@formbricks/lib/constants";
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { createInviteToken, createToken, createTokenForLinkSurvey } from "@formbricks/lib/jwt";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { logger } from "@formbricks/logger";
@@ -270,9 +271,9 @@ export const sendLinkSurveyToVerifiedEmail = async (data: TLinkSurveyEmailData):
const t = await getTranslate();
const getSurveyLink = (): string => {
if (singleUseId) {
return `${WEBAPP_URL}/s/${surveyId}?verify=${encodeURIComponent(token)}&suId=${singleUseId}`;
return `${getSurveyDomain()}/s/${surveyId}?verify=${encodeURIComponent(token)}&suId=${singleUseId}`;
}
return `${WEBAPP_URL}/s/${surveyId}?verify=${encodeURIComponent(token)}`;
return `${getSurveyDomain()}/s/${surveyId}?verify=${encodeURIComponent(token)}`;
};
const surveyLink = getSurveyLink();
@@ -22,7 +22,7 @@ interface LinkSurveyWrapperProps {
IMPRINT_URL?: string;
PRIVACY_URL?: string;
IS_FORMBRICKS_CLOUD: boolean;
webAppUrl: string;
surveyDomain: string;
isBrandingEnabled: boolean;
}
@@ -39,7 +39,7 @@ export const LinkSurveyWrapper = ({
IMPRINT_URL,
PRIVACY_URL,
IS_FORMBRICKS_CLOUD,
webAppUrl,
surveyDomain,
isBrandingEnabled,
}: LinkSurveyWrapperProps) => {
//for embedded survey strip away all surrounding css
@@ -96,7 +96,7 @@ export const LinkSurveyWrapper = ({
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
surveyUrl={webAppUrl + "/s/" + surveyId}
surveyUrl={surveyDomain + "/s/" + surveyId}
/>
</div>
);
@@ -20,6 +20,7 @@ interface LinkSurveyProps {
emailVerificationStatus?: string;
singleUseId?: string;
singleUseResponse?: Pick<Response, "id" | "finished">;
surveyDomain: string;
webAppUrl: string;
responseCount?: number;
verifiedEmail?: string;
@@ -39,6 +40,7 @@ export const LinkSurvey = ({
emailVerificationStatus,
singleUseId,
singleUseResponse,
surveyDomain,
webAppUrl,
responseCount,
verifiedEmail,
@@ -166,7 +168,7 @@ export const LinkSurvey = ({
handleResetSurvey={handleResetSurvey}
determineStyling={determineStyling}
isEmbed={isEmbed}
webAppUrl={webAppUrl}
surveyDomain={surveyDomain}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
@@ -16,6 +16,7 @@ interface PinScreenProps {
emailVerificationStatus?: string;
singleUseId?: string;
singleUseResponse?: Pick<Response, "id" | "finished">;
surveyDomain: string;
webAppUrl: string;
IMPRINT_URL?: string;
PRIVACY_URL?: string;
@@ -32,6 +33,7 @@ export const PinScreen = (props: PinScreenProps) => {
const {
surveyId,
project,
surveyDomain,
webAppUrl,
emailVerificationStatus,
singleUseId,
@@ -118,6 +120,7 @@ export const PinScreen = (props: PinScreenProps) => {
emailVerificationStatus={emailVerificationStatus}
singleUseId={singleUseId}
singleUseResponse={singleUseResponse}
surveyDomain={surveyDomain}
webAppUrl={webAppUrl}
verifiedEmail={verifiedEmail}
languageCode={languageCode}
@@ -10,6 +10,7 @@ import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { type Response } from "@prisma/client";
import { notFound } from "next/navigation";
import { IMPRINT_URL, IS_FORMBRICKS_CLOUD, PRIVACY_URL, WEBAPP_URL } from "@formbricks/lib/constants";
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -98,11 +99,13 @@ export const renderSurvey = async ({
const languageCode = getLanguageCode();
const isSurveyPinProtected = Boolean(survey.pin);
const responseCount = await getResponseCountBySurveyId(survey.id);
const surveyDomain = getSurveyDomain();
if (isSurveyPinProtected) {
return (
<PinScreen
surveyId={survey.id}
surveyDomain={surveyDomain}
project={project}
emailVerificationStatus={emailVerificationStatus}
singleUseId={singleUseId}
@@ -125,6 +128,7 @@ export const renderSurvey = async ({
<LinkSurvey
survey={survey}
project={project}
surveyDomain={surveyDomain}
emailVerificationStatus={emailVerificationStatus}
singleUseId={singleUseId}
singleUseResponse={singleUseResponse}
@@ -1,7 +1,7 @@
import { getSurvey } from "@/modules/survey/lib/survey";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@formbricks/lib/constants";
import { IS_FORMBRICKS_CLOUD, SURVEY_URL, WEBAPP_URL } from "@formbricks/lib/constants";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { TSurvey, TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
import {
@@ -24,6 +24,7 @@ vi.mock("@/modules/survey/link/lib/project", () => ({
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: vi.fn(() => false),
WEBAPP_URL: "https://test.formbricks.com",
SURVEY_URL: "https://surveys.test.formbricks.com",
}));
vi.mock("@formbricks/lib/styling/constants", () => ({
@@ -170,7 +171,7 @@ describe("Metadata Utils", () => {
const result = getSurveyOpenGraphMetadata(surveyId, surveyName);
expect(result).toEqual({
metadataBase: new URL(WEBAPP_URL),
metadataBase: new URL(SURVEY_URL),
openGraph: {
title: surveyName,
description: "Thanks a lot for your time 🙏",
@@ -1,7 +1,8 @@
import { getSurvey } from "@/modules/survey/lib/survey";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { Metadata } from "next";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@formbricks/lib/constants";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
@@ -72,7 +73,7 @@ export const getSurveyOpenGraphMetadata = (surveyId: string, surveyName: string)
const ogImgURL = `/api/v1/og?brandColor=${brandColor}&name=${encodedName}`;
return {
metadataBase: new URL(WEBAPP_URL),
metadataBase: new URL(getSurveyDomain()),
openGraph: {
title: surveyName,
description: "Thanks a lot for your time 🙏",
@@ -16,7 +16,7 @@ interface SurveyCardProps {
survey: TSurvey;
environmentId: string;
isReadOnly: boolean;
WEBAPP_URL: string;
surveyDomain: string;
duplicateSurvey: (survey: TSurvey) => void;
deleteSurvey: (surveyId: string) => void;
locale: TUserLocale;
@@ -25,7 +25,7 @@ export const SurveyCard = ({
survey,
environmentId,
isReadOnly,
WEBAPP_URL,
surveyDomain,
deleteSurvey,
duplicateSurvey,
locale,
@@ -102,7 +102,7 @@ export const SurveyCard = ({
survey={survey}
key={`surveys-${survey.id}`}
environmentId={environmentId}
webAppUrl={WEBAPP_URL}
surveyDomain={surveyDomain}
disabled={isDraftAndReadOnly}
refreshSingleUseId={refreshSingleUseId}
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
@@ -36,7 +36,7 @@ import { CopySurveyModal } from "./copy-survey-modal";
interface SurveyDropDownMenuProps {
environmentId: string;
survey: TSurvey;
webAppUrl: string;
surveyDomain: string;
refreshSingleUseId: () => Promise<string | undefined>;
disabled?: boolean;
isSurveyCreationDeletionDisabled?: boolean;
@@ -47,7 +47,7 @@ interface SurveyDropDownMenuProps {
export const SurveyDropDownMenu = ({
environmentId,
survey,
webAppUrl,
surveyDomain,
refreshSingleUseId,
disabled,
isSurveyCreationDeletionDisabled,
@@ -61,7 +61,7 @@ export const SurveyDropDownMenu = ({
const [isCopyFormOpen, setIsCopyFormOpen] = useState(false);
const router = useRouter();
const surveyUrl = useMemo(() => webAppUrl + "/s/" + survey.id, [survey.id, webAppUrl]);
const surveyLink = useMemo(() => surveyDomain + "/s/" + survey.id, [survey.id, surveyDomain]);
const handleDeleteSurvey = async (surveyId: string) => {
setLoading(true);
@@ -82,7 +82,7 @@ export const SurveyDropDownMenu = ({
e.preventDefault();
setIsDropDownOpen(false);
const newId = await refreshSingleUseId();
const copiedLink = copySurveyLink(surveyUrl, newId);
const copiedLink = copySurveyLink(surveyLink, newId);
navigator.clipboard.writeText(copiedLink);
toast.success(t("common.copied_to_clipboard"));
router.refresh();
@@ -19,7 +19,7 @@ import { SurveyLoading } from "./survey-loading";
interface SurveysListProps {
environmentId: string;
isReadOnly: boolean;
WEBAPP_URL: string;
surveyDomain: string;
userId: string;
surveysPerPage: number;
currentProjectChannel: TProjectConfigChannel;
@@ -37,7 +37,7 @@ export const initialFilters: TSurveyFilters = {
export const SurveysList = ({
environmentId,
isReadOnly,
WEBAPP_URL,
surveyDomain,
userId,
surveysPerPage: surveysLimit,
currentProjectChannel,
@@ -156,7 +156,7 @@ export const SurveysList = ({
survey={survey}
environmentId={environmentId}
isReadOnly={isReadOnly}
WEBAPP_URL={WEBAPP_URL}
surveyDomain={surveyDomain}
duplicateSurvey={handleDuplicateSurvey}
deleteSurvey={handleDeleteSurvey}
locale={locale}
@@ -61,7 +61,7 @@ describe("SurveyDropDownMenu", () => {
<SurveyDropDownMenu
environmentId="env123"
survey={{ ...fakeSurvey, status: "completed" }}
webAppUrl="http://webapp.test"
surveyDomain="http://survey.test"
refreshSingleUseId={mockRefresh}
duplicateSurvey={mockDuplicateSurvey}
deleteSurvey={mockDeleteSurvey}
@@ -93,7 +93,7 @@ describe("SurveyDropDownMenu", () => {
<SurveyDropDownMenu
environmentId="env123"
survey={fakeSurvey}
webAppUrl="http://webapp.test"
surveyDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={vi.fn()}
deleteSurvey={vi.fn()}
+4 -2
View File
@@ -11,7 +11,8 @@ import { PlusIcon } from "lucide-react";
import { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { DEFAULT_LOCALE, SURVEYS_PER_PAGE, WEBAPP_URL } from "@formbricks/lib/constants";
import { DEFAULT_LOCALE, SURVEYS_PER_PAGE } from "@formbricks/lib/constants";
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { getUserLocale } from "@formbricks/lib/user/service";
import { TTemplateRole } from "@formbricks/types/templates";
@@ -32,6 +33,7 @@ export const SurveysPage = async ({
params: paramsProps,
searchParams: searchParamsProps,
}: SurveyTemplateProps) => {
const surveyDomain = getSurveyDomain();
const searchParams = await searchParamsProps;
const params = await paramsProps;
const t = await getTranslate();
@@ -79,7 +81,7 @@ export const SurveysPage = async ({
<SurveysList
environmentId={environment.id}
isReadOnly={isReadOnly}
WEBAPP_URL={WEBAPP_URL}
surveyDomain={surveyDomain}
userId={session.user.id}
surveysPerPage={SURVEYS_PER_PAGE}
currentProjectChannel={currentProjectChannel}
+3
View File
@@ -75,6 +75,9 @@ x-environment: &environment
# Set the below to 0 to disable cron jobs
# DOCKER_CRON_ENABLED: 1
# Set the below to your Survey Domain(default is WEBAPP_URL)
# SURVEY_URL:
################################################### OPTIONAL (STORAGE) ###################################################
# Set the below to set a custom Upload Directory
+1
View File
@@ -254,6 +254,7 @@
"self-hosting/configuration/custom-ssl",
"self-hosting/configuration/environment-variables",
"self-hosting/configuration/smtp",
"self-hosting/configuration/domain-configuration",
{
"group": "Auth & SSO",
"icon": "lock",
@@ -0,0 +1,62 @@
---
title: "Domain Configuration"
description: "Configuring your domain for Formbricks."
icon: "globe"
---
Formbricks supports both single domain and second domain configurations. This guide will help you set up your domains correctly.
## Single Domain Setup
For a single domain setup, you need to configure two essential environment variables:
1. `WEBAPP_URL`: The base URL of your Formbricks instance
2. `NEXTAUTH_URL`: The authentication URL (should be the same as WEBAPP_URL)
### Example Configuration
```bash
WEBAPP_URL=https://formbricks.example.com
NEXTAUTH_URL=https://formbricks.example.com
```
### Important Notes
- Both URLs must be the same for authentication to work properly
- The URLs should be the full URL including the protocol (http:// or https://)
- Make sure your domain is properly configured in your DNS settings
- If you're using HTTPS (recommended), ensure you have valid SSL certificates installed
## Second Domain Setup (Survey Domain)
Formbricks allows you to serve surveys from a different domain than your main application. This is useful for:
- Separating your admin interface from public surveys
- Using a dedicated domain for surveys
### Configuration
To set up a second domain for surveys:
1. Set up DNS records for your survey domain (e.g., `surveys.example.com`)
2. Configure your web server or reverse proxy to route requests from the survey domain to the Formbricks application
3. Set the `SURVEY_URL` environment variable to your survey domain
### Example Configuration
```bash
WEBAPP_URL=https://formbricks.example.com
NEXTAUTH_URL=https://formbricks.example.com
SURVEY_URL=https://surveys.example.com
```
### Important Notes
- The `SURVEY_URL` must be a valid URL with protocol
- When `SURVEY_URL` is set, all public survey links will use this domain.
- If `SURVEY_URL` is not set, surveys will be served from the `WEBAPP_URL` domain
- Make sure your DNS and SSL certificates are properly configured for both domains
- The survey domain should be properly configured in your reverse proxy or web server
- **Important**: The second domain is exclusively for serving surveys. Any non-survey paths accessed on this domain will return a 404 error. This is a security feature to ensure the survey domain only serves survey content.
If you have any questions or require help, feel free to reach out to us on [GitHub Discussions](https://github.com/formbricks/formbricks/discussions).
@@ -65,5 +65,6 @@ These variables are present inside your machines docker-compose file. Restart
| PROMETHEUS_ENABLED | Enables Prometheus metrics if set to 1. | optional | |
| PROMETHEUS_EXPORTER_PORT | Port for Prometheus metrics. | optional | 9090 |
| DOCKER_CRON_ENABLED | Controls whether cron jobs run in the Docker image. Set to 0 to disable (useful for cluster setups). | optional | 1 |
| SURVEY_URL | Set this to change the domain of the survey. | optional | WEBAPP_URL |
Note: If you want to configure something that is not possible via above, please open an issue on our GitHub repo here or reach out to us on Github Discussions and well try our best to work out a solution with you.
+2
View File
@@ -8,6 +8,8 @@ export const IS_FORMBRICKS_CLOUD = env.IS_FORMBRICKS_CLOUD === "1";
export const WEBAPP_URL =
env.WEBAPP_URL || (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : false) || "http://localhost:3000";
export const SURVEY_URL = env.SURVEY_URL;
// encryption keys
export const FORMBRICKS_ENCRYPTION_KEY = env.FORMBRICKS_ENCRYPTION_KEY || undefined;
export const ENCRYPTION_KEY = env.ENCRYPTION_KEY;
+2
View File
@@ -94,6 +94,7 @@ export const env = createEnv({
SMTP_REJECT_UNAUTHORIZED_TLS: z.enum(["1", "0"]).optional(),
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
SURVEY_URL: z.string().optional(),
TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(),
TERMS_URL: z
.string()
@@ -222,6 +223,7 @@ export const env = createEnv({
SMTP_AUTHENTICATED: process.env.SMTP_AUTHENTICATED,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
SURVEY_URL: process.env.SURVEY_URL,
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
TERMS_URL: process.env.TERMS_URL,
+10
View File
@@ -0,0 +1,10 @@
import "server-only";
import { SURVEY_URL, WEBAPP_URL } from "./constants";
/**
* Returns the base URL for public surveys
* Uses SURVEY_URL if set, otherwise falls back to WEBAPP_URL
*/
export const getSurveyDomain = (): string => {
return SURVEY_URL || WEBAPP_URL;
};
+1
View File
@@ -205,6 +205,7 @@
"STRIPE_WEBHOOK_SECRET",
"SURVEYS_PACKAGE_MODE",
"SURVEYS_PACKAGE_BUILD",
"SURVEY_URL",
"TELEMETRY_DISABLED",
"TURNSTILE_SECRET_KEY",
"TERMS_URL",