mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-14 11:30:11 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fcaeefc32e |
@@ -1,14 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { authorizeTraefikRequest } from "@/modules/traefik-auth/service";
|
||||
|
||||
const handler = async (request: NextRequest): Promise<Response> => {
|
||||
return await authorizeTraefikRequest(request);
|
||||
};
|
||||
|
||||
export const GET = handler;
|
||||
export const POST = handler;
|
||||
export const PUT = handler;
|
||||
export const PATCH = handler;
|
||||
export const DELETE = handler;
|
||||
export const HEAD = handler;
|
||||
export const OPTIONS = handler;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getContactByUserId } from "./contact";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react", async () => {
|
||||
const actual = await vi.importActual("react");
|
||||
return {
|
||||
...actual,
|
||||
cache: vi.fn((fn: Function) => fn),
|
||||
};
|
||||
});
|
||||
|
||||
const workspaceId = "test-workspace-id";
|
||||
const userId = "test-user-id";
|
||||
const contact = {
|
||||
id: "test-contact-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
describe("getContactByUserId", () => {
|
||||
test("returns the first contact whose userId attribute exactly matches in the workspace", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: contact.id });
|
||||
|
||||
const result = await getContactByUserId(workspaceId, userId);
|
||||
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
workspaceId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
expect(result).toEqual({ id: contact.id });
|
||||
});
|
||||
|
||||
test("returns null when no contact matches", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
|
||||
|
||||
const result = await getContactByUserId(workspaceId, userId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,14 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { gatewayRequestAuthorizers } from "@/modules/gateway-auth/lib/authorizers";
|
||||
import { authorizeGatewayRequest } from "@/modules/gateway-auth/lib/request";
|
||||
import { buildEnvoyAllowResponse, parseEnvoyRequestMetadata } from "./shared";
|
||||
import { feedbackRecordsEnvoyAuthorizer } from "@/modules/hub/feedback-records-gateway";
|
||||
import {
|
||||
TEnvoyRequestAuthorizer,
|
||||
authenticateEnvoyRequest,
|
||||
buildStatusResponse,
|
||||
parseEnvoyRequestMetadata,
|
||||
} from "./shared";
|
||||
|
||||
const envoyAuthorizers: TEnvoyRequestAuthorizer[] = [feedbackRecordsEnvoyAuthorizer];
|
||||
|
||||
export const authorizeEnvoyRequest = async (request: NextRequest): Promise<Response> => {
|
||||
const requestMetadata = parseEnvoyRequestMetadata(request);
|
||||
@@ -10,12 +16,20 @@ export const authorizeEnvoyRequest = async (request: NextRequest): Promise<Respo
|
||||
return requestMetadata.errorResponse;
|
||||
}
|
||||
|
||||
return await authorizeGatewayRequest({
|
||||
const authorizer = envoyAuthorizers.find((candidate) => candidate.matches(requestMetadata.originalRequest));
|
||||
if (!authorizer) {
|
||||
return buildStatusResponse(400, "Unsupported Envoy auth route");
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateEnvoyRequest(request, authorizer.gatewayToken);
|
||||
if (authenticationResult.status === "missing" || authenticationResult.status === "invalid") {
|
||||
return buildStatusResponse(401, "Unauthorized");
|
||||
}
|
||||
|
||||
return await authorizer.authorize({
|
||||
request,
|
||||
originalRequest: requestMetadata.originalRequest,
|
||||
authorizers: gatewayRequestAuthorizers,
|
||||
principal: authenticationResult.principal,
|
||||
requestId: request.headers.get("x-request-id") ?? "unknown",
|
||||
buildAllowResponse: buildEnvoyAllowResponse,
|
||||
unsupportedRouteMessage: "Unsupported Envoy auth route",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,11 +1,51 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { TGatewayOriginalRequest, buildGatewayStatusResponse } from "@/modules/gateway-auth/lib/request";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateApiKeyFromHeaders, getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
|
||||
import { getProxySession } from "@/modules/auth/lib/proxy-session";
|
||||
|
||||
const ENVOY_AUTH_PREFIX = "/api/envoy-auth";
|
||||
const HEADERS_TO_REMOVE_ON_ALLOW = "x-api-key,authorization,cookie";
|
||||
|
||||
export const buildEnvoyAllowResponse = (): Response =>
|
||||
export type TEnvoyOriginalRequest = {
|
||||
method: string;
|
||||
url: URL;
|
||||
};
|
||||
|
||||
export type TEnvoyAuthenticatedPrincipal =
|
||||
| {
|
||||
type: "apiKey";
|
||||
authentication: TAuthenticationApiKey;
|
||||
}
|
||||
| {
|
||||
type: "user";
|
||||
userId: string;
|
||||
source: "session" | "jwt";
|
||||
};
|
||||
|
||||
export type TEnvoyGatewayTokenHandler = {
|
||||
getTokenFromHeaders: (headers: Headers) => string | null;
|
||||
verifyToken: (token: string) => { userId: string };
|
||||
};
|
||||
|
||||
export type TEnvoyAuthenticationResult =
|
||||
| { status: "authenticated"; principal: TEnvoyAuthenticatedPrincipal }
|
||||
| { status: "invalid" }
|
||||
| { status: "missing" };
|
||||
|
||||
export type TEnvoyRequestAuthorizer = {
|
||||
matches: (originalRequest: TEnvoyOriginalRequest) => boolean;
|
||||
gatewayToken?: TEnvoyGatewayTokenHandler;
|
||||
authorize: (params: {
|
||||
request: NextRequest;
|
||||
originalRequest: TEnvoyOriginalRequest;
|
||||
principal: TEnvoyAuthenticatedPrincipal;
|
||||
requestId: string;
|
||||
}) => Promise<Response>;
|
||||
};
|
||||
|
||||
export const buildAllowResponse = (): Response =>
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
@@ -13,12 +53,20 @@ export const buildEnvoyAllowResponse = (): Response =>
|
||||
},
|
||||
});
|
||||
|
||||
export const buildStatusResponse = (status: number, message: string): Response =>
|
||||
new Response(message, {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
});
|
||||
|
||||
export const parseEnvoyRequestMetadata = (
|
||||
request: NextRequest
|
||||
): { originalRequest: TGatewayOriginalRequest } | { errorResponse: Response } => {
|
||||
): { originalRequest: TEnvoyOriginalRequest } | { errorResponse: Response } => {
|
||||
if (!request.nextUrl.pathname.startsWith(`${ENVOY_AUTH_PREFIX}/`)) {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid Envoy auth request path"),
|
||||
errorResponse: buildStatusResponse(400, "Invalid Envoy auth request path"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,7 +77,7 @@ export const parseEnvoyRequestMetadata = (
|
||||
|
||||
if (originalPathSegments.length === 0) {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Missing original request path"),
|
||||
errorResponse: buildStatusResponse(400, "Missing original request path"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,7 +93,69 @@ export const parseEnvoyRequestMetadata = (
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid original request path"),
|
||||
errorResponse: buildStatusResponse(400, "Invalid original request path"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const authenticateEnvoyRequest = async (
|
||||
request: NextRequest,
|
||||
gatewayToken?: TEnvoyGatewayTokenHandler
|
||||
): Promise<TEnvoyAuthenticationResult> => {
|
||||
if (getApiKeyFromHeaders(request.headers)) {
|
||||
const apiKeyAuthentication = await authenticateApiKeyFromHeaders(request.headers);
|
||||
if (!apiKeyAuthentication) {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "apiKey",
|
||||
authentication: apiKeyAuthentication,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (gatewayToken) {
|
||||
const token = gatewayToken.getTokenFromHeaders(request.headers);
|
||||
if (token) {
|
||||
try {
|
||||
const { userId } = gatewayToken.verifyToken(token);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
|
||||
if (!user || user.isActive === false) {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: user.id,
|
||||
source: "jwt",
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const proxySession = await getProxySession(request);
|
||||
if (!proxySession) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: proxySession.userId,
|
||||
source: "session",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import "server-only";
|
||||
import { feedbackRecordsGatewayAuthorizer } from "@/modules/hub/feedback-records-gateway";
|
||||
import { TGatewayRequestAuthorizer } from "./request";
|
||||
|
||||
export const gatewayRequestAuthorizers: TGatewayRequestAuthorizer[] = [feedbackRecordsGatewayAuthorizer];
|
||||
@@ -1,114 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { authenticateGatewayRequest } from "./request";
|
||||
|
||||
const {
|
||||
mockAuthenticateApiKeyFromHeaders,
|
||||
mockGetApiKeyFromHeaders,
|
||||
mockGetProxySession,
|
||||
mockUserFindUnique,
|
||||
mockLoggerWarn,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAuthenticateApiKeyFromHeaders: vi.fn(),
|
||||
mockGetApiKeyFromHeaders: vi.fn(),
|
||||
mockGetProxySession: vi.fn(),
|
||||
mockUserFindUnique: vi.fn(),
|
||||
mockLoggerWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/lib/api-key-auth", () => ({
|
||||
authenticateApiKeyFromHeaders: mockAuthenticateApiKeyFromHeaders,
|
||||
getApiKeyFromHeaders: mockGetApiKeyFromHeaders,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/proxy-session", () => ({
|
||||
getProxySession: mockGetProxySession,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: mockUserFindUnique,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: mockLoggerWarn,
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("authenticateGatewayRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockGetApiKeyFromHeaders.mockReturnValue(null);
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue(null);
|
||||
mockGetProxySession.mockResolvedValue(null);
|
||||
mockUserFindUnique.mockResolvedValue({ id: "user_1", isActive: true });
|
||||
});
|
||||
|
||||
test("logs and returns invalid when an explicit API key cannot be authenticated", async () => {
|
||||
mockGetApiKeyFromHeaders.mockReturnValue("fbk_invalid");
|
||||
|
||||
const result = await authenticateGatewayRequest(new NextRequest("http://localhost/test"));
|
||||
|
||||
expect(result).toEqual({ status: "invalid" });
|
||||
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||
{ hasApiKey: true, reason: "invalid_api_key" },
|
||||
"Gateway authentication failed"
|
||||
);
|
||||
});
|
||||
|
||||
test("logs and returns invalid when gateway token verification fails", async () => {
|
||||
const verifyError = new Error("invalid token");
|
||||
|
||||
const result = await authenticateGatewayRequest(new NextRequest("http://localhost/test"), {
|
||||
getTokenFromHeaders: () => "header.payload.signature",
|
||||
verifyToken: () => {
|
||||
throw verifyError;
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ status: "invalid" });
|
||||
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||
{ error: verifyError, hasToken: true, reason: "token_verification_failed" },
|
||||
"Gateway authentication failed"
|
||||
);
|
||||
});
|
||||
|
||||
test("logs and returns invalid when the gateway token user is inactive", async () => {
|
||||
mockUserFindUnique.mockResolvedValue({ id: "user_1", isActive: false });
|
||||
|
||||
const result = await authenticateGatewayRequest(new NextRequest("http://localhost/test"), {
|
||||
getTokenFromHeaders: () => "header.payload.signature",
|
||||
verifyToken: () => ({ userId: "user_1" }),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ status: "invalid" });
|
||||
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||
{
|
||||
hasToken: true,
|
||||
reason: "user_missing_or_inactive",
|
||||
userId: "user_1",
|
||||
userFound: true,
|
||||
isActive: false,
|
||||
},
|
||||
"Gateway authentication failed"
|
||||
);
|
||||
});
|
||||
|
||||
test("propagates user lookup errors instead of converting them into invalid auth", async () => {
|
||||
const lookupError = new Error("database unavailable");
|
||||
mockUserFindUnique.mockRejectedValue(lookupError);
|
||||
|
||||
await expect(
|
||||
authenticateGatewayRequest(new NextRequest("http://localhost/test"), {
|
||||
getTokenFromHeaders: () => "header.payload.signature",
|
||||
verifyToken: () => ({ userId: "user_1" }),
|
||||
})
|
||||
).rejects.toThrow("database unavailable");
|
||||
});
|
||||
});
|
||||
@@ -1,171 +0,0 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateApiKeyFromHeaders, getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
|
||||
import { getProxySession } from "@/modules/auth/lib/proxy-session";
|
||||
|
||||
export type TGatewayOriginalRequest = {
|
||||
method: string;
|
||||
url: URL;
|
||||
};
|
||||
|
||||
export type TGatewayAuthenticatedPrincipal =
|
||||
| {
|
||||
type: "apiKey";
|
||||
authentication: TAuthenticationApiKey;
|
||||
}
|
||||
| {
|
||||
type: "user";
|
||||
userId: string;
|
||||
source: "session" | "jwt";
|
||||
};
|
||||
|
||||
export type TGatewayTokenHandler = {
|
||||
getTokenFromHeaders: (headers: Headers) => string | null;
|
||||
verifyToken: (token: string) => { userId: string };
|
||||
};
|
||||
|
||||
export type TGatewayAuthenticationResult =
|
||||
| { status: "authenticated"; principal: TGatewayAuthenticatedPrincipal }
|
||||
| { status: "invalid" }
|
||||
| { status: "missing" };
|
||||
|
||||
export type TGatewayAuthorizationDecision = { status: "allow" } | { status: "deny"; response: Response };
|
||||
|
||||
export type TGatewayRequestAuthorizer = {
|
||||
matches: (originalRequest: TGatewayOriginalRequest) => boolean;
|
||||
gatewayToken?: TGatewayTokenHandler;
|
||||
authorize: (params: {
|
||||
request: NextRequest;
|
||||
originalRequest: TGatewayOriginalRequest;
|
||||
principal: TGatewayAuthenticatedPrincipal;
|
||||
requestId: string;
|
||||
}) => Promise<TGatewayAuthorizationDecision>;
|
||||
};
|
||||
|
||||
export const buildGatewayStatusResponse = (status: number, message: string): Response =>
|
||||
new Response(message, {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
});
|
||||
|
||||
export const allowGatewayRequest = (): TGatewayAuthorizationDecision => ({ status: "allow" });
|
||||
|
||||
export const authenticateGatewayRequest = async (
|
||||
request: NextRequest,
|
||||
gatewayToken?: TGatewayTokenHandler
|
||||
): Promise<TGatewayAuthenticationResult> => {
|
||||
if (getApiKeyFromHeaders(request.headers)) {
|
||||
const apiKeyAuthentication = await authenticateApiKeyFromHeaders(request.headers);
|
||||
if (!apiKeyAuthentication) {
|
||||
logger.warn({ hasApiKey: true, reason: "invalid_api_key" }, "Gateway authentication failed");
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "apiKey",
|
||||
authentication: apiKeyAuthentication,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (gatewayToken) {
|
||||
const token = gatewayToken.getTokenFromHeaders(request.headers);
|
||||
if (token) {
|
||||
let userId: string;
|
||||
|
||||
try {
|
||||
({ userId } = gatewayToken.verifyToken(token));
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error, hasToken: true, reason: "token_verification_failed" },
|
||||
"Gateway authentication failed"
|
||||
);
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
|
||||
if (!user || user.isActive === false) {
|
||||
logger.warn(
|
||||
{
|
||||
hasToken: true,
|
||||
reason: "user_missing_or_inactive",
|
||||
userId,
|
||||
userFound: Boolean(user),
|
||||
isActive: user?.isActive ?? null,
|
||||
},
|
||||
"Gateway authentication failed"
|
||||
);
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: user.id,
|
||||
source: "jwt",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const proxySession = await getProxySession(request);
|
||||
if (!proxySession) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: proxySession.userId,
|
||||
source: "session",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const authorizeGatewayRequest = async ({
|
||||
request,
|
||||
originalRequest,
|
||||
authorizers,
|
||||
requestId,
|
||||
buildAllowResponse,
|
||||
unsupportedRouteMessage,
|
||||
}: {
|
||||
request: NextRequest;
|
||||
originalRequest: TGatewayOriginalRequest;
|
||||
authorizers: TGatewayRequestAuthorizer[];
|
||||
requestId: string;
|
||||
buildAllowResponse: () => Response;
|
||||
unsupportedRouteMessage: string;
|
||||
}): Promise<Response> => {
|
||||
const authorizer = authorizers.find((candidate) => candidate.matches(originalRequest));
|
||||
if (!authorizer) {
|
||||
return buildGatewayStatusResponse(400, unsupportedRouteMessage);
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateGatewayRequest(request, authorizer.gatewayToken);
|
||||
if (authenticationResult.status === "missing" || authenticationResult.status === "invalid") {
|
||||
return buildGatewayStatusResponse(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const authorizationDecision = await authorizer.authorize({
|
||||
request,
|
||||
originalRequest,
|
||||
principal: authenticationResult.principal,
|
||||
requestId,
|
||||
});
|
||||
|
||||
return authorizationDecision.status === "allow" ? buildAllowResponse() : authorizationDecision.response;
|
||||
};
|
||||
@@ -12,11 +12,11 @@ import { getBearerTokenFromHeaders } from "@/modules/api/lib/api-key-auth";
|
||||
import { getFeedbackDirectoryAuthContext } from "@/modules/ee/feedback-directory/lib/feedback-directory";
|
||||
import { getIsUnifyFeedbackEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import {
|
||||
TGatewayAuthenticatedPrincipal,
|
||||
TGatewayRequestAuthorizer,
|
||||
allowGatewayRequest,
|
||||
buildGatewayStatusResponse,
|
||||
} from "@/modules/gateway-auth/lib/request";
|
||||
TEnvoyAuthenticatedPrincipal,
|
||||
TEnvoyRequestAuthorizer,
|
||||
buildAllowResponse,
|
||||
buildStatusResponse,
|
||||
} from "@/modules/envoy-auth/shared";
|
||||
import { getFeedbackRecordTenant } from "@/modules/hub/service";
|
||||
|
||||
const FEEDBACK_RECORDS_V3_PREFIX = "/api/v3/feedbackRecords";
|
||||
@@ -137,7 +137,7 @@ const parseFeedbackRecordsGatewayRoute = (method: string, pathname: string): TPa
|
||||
return null;
|
||||
};
|
||||
|
||||
type TAuthenticatedGatewayPrincipal = TGatewayAuthenticatedPrincipal;
|
||||
type TAuthenticatedGatewayPrincipal = TEnvoyAuthenticatedPrincipal;
|
||||
|
||||
const parseTenantId = (tenantId: string | null): string | null => {
|
||||
if (!tenantId) {
|
||||
@@ -200,7 +200,7 @@ const resolveTenantId = async (
|
||||
const tenantId = parseTenantId(originalUrl.searchParams.get("tenant_id"));
|
||||
if (!tenantId) {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
errorResponse: buildStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ const resolveTenantId = async (
|
||||
const tenantId = parseTenantId(typeof body?.tenant_id === "string" ? body.tenant_id : null);
|
||||
if (!tenantId) {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
errorResponse: buildStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ const resolveTenantId = async (
|
||||
"Feedback record tenant lookup returned not found"
|
||||
);
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(403, "Forbidden"),
|
||||
errorResponse: buildStatusResponse(403, "Forbidden"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ const resolveTenantId = async (
|
||||
"Feedback record tenant lookup failed"
|
||||
);
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(503, "Feedback record lookup failed"),
|
||||
errorResponse: buildStatusResponse(503, "Feedback record lookup failed"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,14 +247,14 @@ const resolveTenantId = async (
|
||||
"Feedback record tenant lookup returned invalid tenant"
|
||||
);
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(503, "Feedback record lookup failed"),
|
||||
errorResponse: buildStatusResponse(503, "Feedback record lookup failed"),
|
||||
};
|
||||
}
|
||||
|
||||
return { tenantId };
|
||||
};
|
||||
|
||||
const authorizeFeedbackRecordsGatewayRequest = async (
|
||||
const authorizeGatewayRequest = async (
|
||||
principal: TAuthenticatedGatewayPrincipal,
|
||||
feedbackDirectoryId: string,
|
||||
requiredPermission: TFeedbackRecordsGatewayPermission
|
||||
@@ -308,7 +308,7 @@ const authorizeFeedbackRecordsGatewayRequest = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const feedbackRecordsGatewayAuthorizer: TGatewayRequestAuthorizer = {
|
||||
export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
|
||||
matches: (originalRequest) => normalizeFeedbackRecordsPath(originalRequest.url.pathname) !== null,
|
||||
gatewayToken: {
|
||||
getTokenFromHeaders: getFeedbackRecordsGatewayJwtFromHeaders,
|
||||
@@ -317,21 +317,15 @@ export const feedbackRecordsGatewayAuthorizer: TGatewayRequestAuthorizer = {
|
||||
authorize: async ({ request, originalRequest, principal, requestId }) => {
|
||||
const route = parseFeedbackRecordsGatewayRoute(originalRequest.method, originalRequest.url.pathname);
|
||||
if (!route) {
|
||||
return {
|
||||
status: "deny",
|
||||
response: buildGatewayStatusResponse(400, "Unsupported FeedbackRecords route"),
|
||||
};
|
||||
return buildStatusResponse(400, "Unsupported FeedbackRecords route");
|
||||
}
|
||||
|
||||
const tenantResolution = await resolveTenantId(request, route, originalRequest.url, requestId);
|
||||
if ("errorResponse" in tenantResolution) {
|
||||
return {
|
||||
status: "deny",
|
||||
response: tenantResolution.errorResponse,
|
||||
};
|
||||
return tenantResolution.errorResponse;
|
||||
}
|
||||
|
||||
const authorizationResult = await authorizeFeedbackRecordsGatewayRequest(
|
||||
const authorizationResult = await authorizeGatewayRequest(
|
||||
principal,
|
||||
tenantResolution.tenantId,
|
||||
route.requiredPermission
|
||||
@@ -347,10 +341,7 @@ export const feedbackRecordsGatewayAuthorizer: TGatewayRequestAuthorizer = {
|
||||
},
|
||||
"Feedback records gateway authorization denied"
|
||||
);
|
||||
return {
|
||||
status: "deny",
|
||||
response: buildGatewayStatusResponse(403, "Forbidden"),
|
||||
};
|
||||
return buildStatusResponse(403, "Forbidden");
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -364,6 +355,6 @@ export const feedbackRecordsGatewayAuthorizer: TGatewayRequestAuthorizer = {
|
||||
"Feedback records gateway authorization allowed"
|
||||
);
|
||||
|
||||
return allowGatewayRequest();
|
||||
return buildAllowResponse();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ interface PinScreenProps {
|
||||
isEmbed: boolean;
|
||||
isPreview: boolean;
|
||||
contactId?: string;
|
||||
canReadUserIdFromUrl?: boolean;
|
||||
recaptchaSiteKey?: string;
|
||||
isSpamProtectionEnabled?: boolean;
|
||||
responseCount?: number;
|
||||
@@ -48,6 +49,7 @@ export const PinScreen = (props: PinScreenProps) => {
|
||||
isEmbed,
|
||||
isPreview,
|
||||
contactId,
|
||||
canReadUserIdFromUrl = false,
|
||||
recaptchaSiteKey,
|
||||
isSpamProtectionEnabled = false,
|
||||
responseCount,
|
||||
@@ -130,6 +132,7 @@ export const PinScreen = (props: PinScreenProps) => {
|
||||
singleUseId={singleUseId}
|
||||
singleUseResponseId={singleUseResponse?.id}
|
||||
contactId={contactId}
|
||||
canReadUserIdFromUrl={canReadUserIdFromUrl}
|
||||
recaptchaSiteKey={recaptchaSiteKey}
|
||||
isSpamProtectionEnabled={isSpamProtectionEnabled}
|
||||
isPreview={isPreview}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CustomScriptsInjector } from "@/modules/survey/link/components/custom-s
|
||||
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
|
||||
import { OfflineAlert } from "@/modules/survey/link/components/offline-alert";
|
||||
import { getPrefillValue } from "@/modules/survey/link/lib/prefill";
|
||||
import { getUserIdFromSearchParams } from "@/modules/survey/link/lib/user-id";
|
||||
import { isRTLLanguage } from "@/modules/survey/link/lib/utils";
|
||||
import { SurveyInline } from "@/modules/ui/components/survey";
|
||||
|
||||
@@ -25,6 +26,7 @@ interface SurveyClientWrapperProps {
|
||||
singleUseId?: string;
|
||||
singleUseResponseId?: string;
|
||||
contactId?: string;
|
||||
canReadUserIdFromUrl?: boolean;
|
||||
recaptchaSiteKey?: string;
|
||||
isSpamProtectionEnabled: boolean;
|
||||
isPreview: boolean;
|
||||
@@ -49,6 +51,7 @@ export const SurveyClientWrapper = ({
|
||||
singleUseId,
|
||||
singleUseResponseId,
|
||||
contactId,
|
||||
canReadUserIdFromUrl = false,
|
||||
recaptchaSiteKey,
|
||||
isSpamProtectionEnabled,
|
||||
isPreview,
|
||||
@@ -61,6 +64,7 @@ export const SurveyClientWrapper = ({
|
||||
const searchParams = useSearchParams();
|
||||
const skipPrefilled = searchParams.get("skipPrefilled") === "true";
|
||||
const offlineSupport = searchParams.get("offlineSupport") === "true";
|
||||
const userId = canReadUserIdFromUrl ? getUserIdFromSearchParams(searchParams) : undefined;
|
||||
const elements = useMemo(() => getElementsFromBlocks(survey.blocks), [survey.blocks]);
|
||||
|
||||
const startAt = searchParams.get("startAt");
|
||||
@@ -194,6 +198,7 @@ export const SurveyClientWrapper = ({
|
||||
singleUseResponseId={singleUseResponseId}
|
||||
getSetIsResponseSendingFinished={(_f: (value: boolean) => void) => {}}
|
||||
contactId={contactId}
|
||||
userId={userId}
|
||||
recaptchaSiteKey={recaptchaSiteKey}
|
||||
isSpamProtectionEnabled={isSpamProtectionEnabled}
|
||||
offlineSupport={offlineSupport}
|
||||
|
||||
@@ -12,26 +12,31 @@ import {
|
||||
TERMS_URL,
|
||||
} from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { PinScreen } from "@/modules/survey/link/components/pin-screen";
|
||||
import { SurveyClientWrapper } from "@/modules/survey/link/components/survey-client-wrapper";
|
||||
import { SurveyCompletedMessage } from "@/modules/survey/link/components/survey-completed-message";
|
||||
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
|
||||
import { VerifyEmail } from "@/modules/survey/link/components/verify-email";
|
||||
import { getEmailVerificationDetails } from "@/modules/survey/link/lib/helper";
|
||||
import { hasUserIdSearchParam } from "@/modules/survey/link/lib/user-id";
|
||||
import { TWorkspaceContextForLinkSurvey } from "@/modules/survey/link/lib/workspace";
|
||||
|
||||
type TLinkSurveySearchParams = {
|
||||
verify?: string;
|
||||
lang?: string;
|
||||
embed?: string;
|
||||
preview?: string;
|
||||
suId?: string;
|
||||
} & Record<string, string | string[] | undefined>;
|
||||
|
||||
interface SurveyRendererProps {
|
||||
survey: TSurvey;
|
||||
searchParams: {
|
||||
verify?: string;
|
||||
lang?: string;
|
||||
embed?: string;
|
||||
preview?: string;
|
||||
suId?: string;
|
||||
};
|
||||
searchParams: TLinkSurveySearchParams;
|
||||
singleUseId?: string;
|
||||
singleUseResponse?: Pick<Response, "id" | "finished">;
|
||||
contactId?: string;
|
||||
allowUrlUserIdLookup?: boolean;
|
||||
isPreview: boolean;
|
||||
// New props - pre-fetched in parent
|
||||
workspaceContext: TWorkspaceContextForLinkSurvey;
|
||||
@@ -46,7 +51,7 @@ interface SurveyRendererProps {
|
||||
* database queries. The parent (page.tsx) fetches data in parallel stages
|
||||
* to minimize latency for users geographically distant from servers.
|
||||
*
|
||||
* @param environmentContext - Pre-fetched workspace and organization data
|
||||
* @param workspaceContext - Pre-fetched workspace and organization data
|
||||
* @param locale - User's locale from Accept-Language header
|
||||
* @param responseCount - Conditionally fetched if showResponseCount is enabled
|
||||
*/
|
||||
@@ -56,6 +61,7 @@ export const renderSurvey = async ({
|
||||
singleUseId,
|
||||
singleUseResponse,
|
||||
contactId,
|
||||
allowUrlUserIdLookup = false,
|
||||
isPreview,
|
||||
workspaceContext,
|
||||
locale,
|
||||
@@ -131,6 +137,10 @@ export const renderSurvey = async ({
|
||||
const styling = computeStyling(workspace.styling, survey.styling);
|
||||
const languageCode = getLanguageCode(langParam, survey);
|
||||
const publicDomain = getPublicDomain();
|
||||
const canReadUserIdFromUrl =
|
||||
allowUrlUserIdLookup && !contactId && hasUserIdSearchParam(searchParams)
|
||||
? await getIsContactsEnabled(workspaceContext.organizationId)
|
||||
: false;
|
||||
|
||||
// Handle PIN-protected surveys
|
||||
if (survey.pin) {
|
||||
@@ -151,6 +161,7 @@ export const renderSurvey = async ({
|
||||
isEmbed={isEmbed}
|
||||
isPreview={isPreview}
|
||||
contactId={contactId}
|
||||
canReadUserIdFromUrl={canReadUserIdFromUrl}
|
||||
recaptchaSiteKey={RECAPTCHA_SITE_KEY}
|
||||
isSpamProtectionEnabled={isSpamProtectionEnabled}
|
||||
responseCount={responseCount}
|
||||
@@ -171,6 +182,7 @@ export const renderSurvey = async ({
|
||||
singleUseId={singleUseId}
|
||||
singleUseResponseId={singleUseResponse?.id}
|
||||
contactId={contactId}
|
||||
canReadUserIdFromUrl={canReadUserIdFromUrl}
|
||||
recaptchaSiteKey={RECAPTCHA_SITE_KEY}
|
||||
isSpamProtectionEnabled={isSpamProtectionEnabled}
|
||||
isPreview={isPreview}
|
||||
|
||||
@@ -17,17 +17,19 @@ import {
|
||||
import { getWorkspaceContextForLinkSurvey } from "@/modules/survey/link/lib/workspace";
|
||||
import { getWorkspaceById } from "@/modules/survey/link/lib/workspace";
|
||||
|
||||
type TContactSurveyPageSearchParams = {
|
||||
suId?: string;
|
||||
verify?: string;
|
||||
lang?: string;
|
||||
embed?: string;
|
||||
preview?: string;
|
||||
} & Record<string, string | string[] | undefined>;
|
||||
|
||||
interface ContactSurveyPageProps {
|
||||
params: Promise<{
|
||||
jwt: string;
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
suId?: string;
|
||||
verify?: string;
|
||||
lang?: string;
|
||||
embed?: string;
|
||||
preview?: string;
|
||||
}>;
|
||||
searchParams: Promise<TContactSurveyPageSearchParams>;
|
||||
}
|
||||
|
||||
export const generateMetadata = async (props: ContactSurveyPageProps): Promise<Metadata> => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getUserIdFromSearchParams, hasUserIdSearchParam } from "./user-id";
|
||||
|
||||
describe("getUserIdFromSearchParams", () => {
|
||||
test("returns the first case-insensitive userId value in URL order", () => {
|
||||
const searchParams = new URLSearchParams("foo=bar&UserID=first&userId=second");
|
||||
|
||||
expect(getUserIdFromSearchParams(searchParams)).toBe("first");
|
||||
});
|
||||
|
||||
test("keeps the exact decoded value without trimming or case normalization", () => {
|
||||
const searchParams = new URLSearchParams("userId=%20Test%40Example.com%20");
|
||||
|
||||
expect(getUserIdFromSearchParams(searchParams)).toBe(" Test@Example.com ");
|
||||
});
|
||||
|
||||
test("ignores empty values", () => {
|
||||
const searchParams = new URLSearchParams("userId=");
|
||||
|
||||
expect(getUserIdFromSearchParams(searchParams)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined when no userId parameter exists", () => {
|
||||
const searchParams = new URLSearchParams("source=email");
|
||||
|
||||
expect(getUserIdFromSearchParams(searchParams)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("supports plain Next.js searchParams objects", () => {
|
||||
expect(getUserIdFromSearchParams({ source: "email", UserID: "from-object" })).toBe("from-object");
|
||||
});
|
||||
|
||||
test("supports array values in plain searchParams objects", () => {
|
||||
expect(getUserIdFromSearchParams({ userId: ["first", "second"] })).toBe("first");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasUserIdSearchParam", () => {
|
||||
test("returns true when a non-empty userId parameter exists", () => {
|
||||
expect(hasUserIdSearchParam(new URLSearchParams("userId=abc"))).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when the userId parameter is empty", () => {
|
||||
expect(hasUserIdSearchParam(new URLSearchParams("userId="))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
type TSearchParamValue = string | string[] | undefined;
|
||||
|
||||
type TSearchParamsWithEntries = Pick<URLSearchParams, "entries">;
|
||||
|
||||
type TSearchParamsRecord = Record<string, TSearchParamValue>;
|
||||
|
||||
type TUserIdSearchParams = TSearchParamsWithEntries | TSearchParamsRecord;
|
||||
|
||||
function* getSearchParamEntries(searchParams: TUserIdSearchParams): Generator<[string, string]> {
|
||||
if ("entries" in searchParams && typeof searchParams.entries === "function") {
|
||||
yield* searchParams.entries();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(searchParams)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const arrayValue of value) {
|
||||
yield [key, arrayValue];
|
||||
}
|
||||
} else if (value !== undefined) {
|
||||
yield [key, value];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getUserIdFromSearchParams = (searchParams: TUserIdSearchParams): string | undefined => {
|
||||
for (const [key, value] of getSearchParamEntries(searchParams)) {
|
||||
if (key.toLowerCase() === "userid") {
|
||||
return value === "" ? undefined : value;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const hasUserIdSearchParam = (searchParams: TUserIdSearchParams): boolean => {
|
||||
return getUserIdFromSearchParams(searchParams) !== undefined;
|
||||
};
|
||||
@@ -12,17 +12,19 @@ import { checkAndValidateSingleUseId } from "@/modules/survey/link/lib/helper";
|
||||
import { getWorkspaceContextForLinkSurvey } from "@/modules/survey/link/lib/workspace";
|
||||
import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
|
||||
|
||||
type TLinkSurveyPageSearchParams = {
|
||||
suId?: string;
|
||||
verify?: string;
|
||||
lang?: string;
|
||||
embed?: string;
|
||||
preview?: string;
|
||||
} & Record<string, string | string[] | undefined>;
|
||||
|
||||
interface LinkSurveyPageProps {
|
||||
params: Promise<{
|
||||
surveyId: string;
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
suId?: string;
|
||||
verify?: string;
|
||||
lang?: string;
|
||||
embed?: string;
|
||||
preview?: string;
|
||||
}>;
|
||||
searchParams: Promise<TLinkSurveyPageSearchParams>;
|
||||
}
|
||||
|
||||
export const generateMetadata = async (props: LinkSurveyPageProps): Promise<Metadata> => {
|
||||
@@ -121,6 +123,7 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
|
||||
searchParams,
|
||||
singleUseId,
|
||||
singleUseResponse: singleUseResponse ?? undefined,
|
||||
allowUrlUserIdLookup: true,
|
||||
isPreview,
|
||||
workspaceContext,
|
||||
locale,
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { authorizeTraefikRequest } from "./service";
|
||||
|
||||
const {
|
||||
mockAuthenticateApiKeyFromHeaders,
|
||||
mockGetApiKeyFromHeaders,
|
||||
mockGetBearerTokenFromHeaders,
|
||||
mockGetProxySession,
|
||||
mockVerifyFeedbackRecordsGatewayToken,
|
||||
mockGetFeedbackDirectoryAuthContext,
|
||||
mockGetFeedbackRecordTenant,
|
||||
mockCheckAuthorizationUpdated,
|
||||
mockUserFindUnique,
|
||||
mockGetIsUnifyFeedbackEnabled,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAuthenticateApiKeyFromHeaders: vi.fn(),
|
||||
mockGetApiKeyFromHeaders: vi.fn(),
|
||||
mockGetBearerTokenFromHeaders: vi.fn(),
|
||||
mockGetProxySession: vi.fn(),
|
||||
mockVerifyFeedbackRecordsGatewayToken: vi.fn(),
|
||||
mockGetFeedbackDirectoryAuthContext: vi.fn(),
|
||||
mockGetFeedbackRecordTenant: vi.fn(),
|
||||
mockCheckAuthorizationUpdated: vi.fn(),
|
||||
mockUserFindUnique: vi.fn(),
|
||||
mockGetIsUnifyFeedbackEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/lib/api-key-auth", () => ({
|
||||
authenticateApiKeyFromHeaders: mockAuthenticateApiKeyFromHeaders,
|
||||
getApiKeyFromHeaders: mockGetApiKeyFromHeaders,
|
||||
getBearerTokenFromHeaders: mockGetBearerTokenFromHeaders,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/proxy-session", () => ({
|
||||
getProxySession: mockGetProxySession,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jwt", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/jwt")>();
|
||||
return {
|
||||
...actual,
|
||||
verifyFeedbackRecordsGatewayToken: mockVerifyFeedbackRecordsGatewayToken,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: mockUserFindUnique,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/feedback-directory/lib/feedback-directory", () => ({
|
||||
getFeedbackDirectoryAuthContext: mockGetFeedbackDirectoryAuthContext,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsUnifyFeedbackEnabled: mockGetIsUnifyFeedbackEnabled,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/hub/service", () => ({
|
||||
getFeedbackRecordTenant: mockGetFeedbackRecordTenant,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: mockCheckAuthorizationUpdated,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const feedbackDirectoryId = "clxx1234567890123456789012";
|
||||
const feedbackRecordId = "0194d8a0-3d55-7ff4-9f62-8d02c3fbcfe8";
|
||||
|
||||
const createRequest = ({
|
||||
method = "GET",
|
||||
forwardedMethod = "GET",
|
||||
forwardedUri,
|
||||
headers = {},
|
||||
body,
|
||||
adapterUrl = "http://localhost/api/traefik-auth/feedback-records",
|
||||
}: {
|
||||
method?: string;
|
||||
forwardedMethod?: string;
|
||||
forwardedUri?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: BodyInit;
|
||||
adapterUrl?: string;
|
||||
} = {}) =>
|
||||
new NextRequest(adapterUrl, {
|
||||
method,
|
||||
headers: {
|
||||
"x-forwarded-method": forwardedMethod,
|
||||
...(forwardedUri ? { "x-forwarded-uri": forwardedUri } : {}),
|
||||
"x-forwarded-host": "app.example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
...headers,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
describe("authorizeTraefikRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockGetApiKeyFromHeaders.mockReturnValue(null);
|
||||
mockGetBearerTokenFromHeaders.mockReturnValue(null);
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue(null);
|
||||
mockGetProxySession.mockResolvedValue(null);
|
||||
mockVerifyFeedbackRecordsGatewayToken.mockImplementation(() => {
|
||||
throw new Error("invalid token");
|
||||
});
|
||||
mockGetFeedbackDirectoryAuthContext.mockResolvedValue({
|
||||
organizationId: "org_1",
|
||||
workspaceIds: ["workspace_1"],
|
||||
isArchived: false,
|
||||
});
|
||||
mockGetFeedbackRecordTenant.mockResolvedValue({
|
||||
data: { tenantId: feedbackDirectoryId },
|
||||
error: null,
|
||||
});
|
||||
mockCheckAuthorizationUpdated.mockResolvedValue(true);
|
||||
mockUserFindUnique.mockResolvedValue({ id: "user_1", isActive: true });
|
||||
mockGetIsUnifyFeedbackEnabled.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("allows requests using Traefik forwarded method and URI metadata", async () => {
|
||||
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
});
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
method: "POST",
|
||||
forwardedMethod: "POST",
|
||||
forwardedUri: "/api/v3/feedbackRecords",
|
||||
headers: {
|
||||
authorization: "Bearer fbk_test",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ tenant_id: feedbackDirectoryId }),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("x-envoy-auth-headers-to-remove")).toBeNull();
|
||||
});
|
||||
|
||||
test("uses the forwarded URI instead of the Traefik auth endpoint URL", async () => {
|
||||
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
});
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
adapterUrl: "http://localhost/api/traefik-auth/not-the-original-route",
|
||||
forwardedUri: `/v1/feedback-records?tenant_id=${feedbackDirectoryId}`,
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("returns 400 when Traefik forwarded metadata is missing", async () => {
|
||||
const response = await authorizeTraefikRequest(
|
||||
new NextRequest("http://localhost/api/traefik-auth/v1/feedback-records", {
|
||||
method: "GET",
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test("authorizes record lookups through the shared FeedbackRecords authorizer", async () => {
|
||||
mockGetBearerTokenFromHeaders.mockReturnValue("header.payload.signature");
|
||||
mockVerifyFeedbackRecordsGatewayToken.mockReturnValue({ userId: "user_1" });
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
forwardedMethod: "PATCH",
|
||||
forwardedUri: `/v1/feedback-records/${feedbackRecordId}`,
|
||||
headers: {
|
||||
authorization: "Bearer header.payload.signature",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetFeedbackRecordTenant).toHaveBeenCalledWith(feedbackRecordId);
|
||||
expect(mockCheckAuthorizationUpdated).toHaveBeenCalledWith({
|
||||
userId: "user_1",
|
||||
organizationId: "org_1",
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
workspaceId: "workspace_1",
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 401 for invalid explicit JWT instead of falling back to session cookies", async () => {
|
||||
mockGetBearerTokenFromHeaders.mockReturnValue("header.payload.signature");
|
||||
mockGetProxySession.mockResolvedValue({
|
||||
userId: "user_1",
|
||||
});
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
forwardedUri: `/v1/feedback-records?tenant_id=${feedbackDirectoryId}`,
|
||||
headers: {
|
||||
authorization: "Bearer header.payload.signature",
|
||||
cookie: "next-auth.session-token=still-present",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(mockGetProxySession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { gatewayRequestAuthorizers } from "@/modules/gateway-auth/lib/authorizers";
|
||||
import { authorizeGatewayRequest } from "@/modules/gateway-auth/lib/request";
|
||||
import { buildTraefikAllowResponse, parseTraefikRequestMetadata } from "./shared";
|
||||
|
||||
export const authorizeTraefikRequest = async (request: NextRequest): Promise<Response> => {
|
||||
const requestMetadata = parseTraefikRequestMetadata(request);
|
||||
if ("errorResponse" in requestMetadata) {
|
||||
return requestMetadata.errorResponse;
|
||||
}
|
||||
|
||||
return await authorizeGatewayRequest({
|
||||
request,
|
||||
originalRequest: requestMetadata.originalRequest,
|
||||
authorizers: gatewayRequestAuthorizers,
|
||||
requestId: request.headers.get("x-request-id") ?? request.headers.get("x-forwarded-for") ?? "unknown",
|
||||
buildAllowResponse: buildTraefikAllowResponse,
|
||||
unsupportedRouteMessage: "Unsupported Traefik auth route",
|
||||
});
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { TGatewayOriginalRequest, buildGatewayStatusResponse } from "@/modules/gateway-auth/lib/request";
|
||||
|
||||
const TRAEFIK_AUTH_PREFIX = "/api/traefik-auth";
|
||||
|
||||
const isTraefikAuthPath = (pathname: string): boolean =>
|
||||
pathname === TRAEFIK_AUTH_PREFIX || pathname.startsWith(`${TRAEFIK_AUTH_PREFIX}/`);
|
||||
|
||||
const buildForwardedRequestUrl = (request: NextRequest, forwardedUri: string): URL => {
|
||||
if (forwardedUri.startsWith("http://") || forwardedUri.startsWith("https://")) {
|
||||
return new URL(forwardedUri);
|
||||
}
|
||||
|
||||
const proto = request.headers.get("x-forwarded-proto") || "https";
|
||||
const host = request.headers.get("x-forwarded-host") || request.headers.get("host") || "traefik-auth.local";
|
||||
const normalizedUri = forwardedUri.startsWith("/") ? forwardedUri : `/${forwardedUri}`;
|
||||
|
||||
return new URL(normalizedUri, `${proto}://${host}`);
|
||||
};
|
||||
|
||||
export const buildTraefikAllowResponse = (): Response => new Response(null, { status: 200 });
|
||||
|
||||
export const parseTraefikRequestMetadata = (
|
||||
request: NextRequest
|
||||
): { originalRequest: TGatewayOriginalRequest } | { errorResponse: Response } => {
|
||||
if (!isTraefikAuthPath(request.nextUrl.pathname)) {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid Traefik auth request path"),
|
||||
};
|
||||
}
|
||||
|
||||
const forwardedMethod = request.headers.get("x-forwarded-method")?.trim();
|
||||
const forwardedUri = request.headers.get("x-forwarded-uri")?.trim();
|
||||
|
||||
if (!forwardedMethod || !forwardedUri) {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Missing original request metadata"),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
originalRequest: {
|
||||
method: forwardedMethod.toUpperCase(),
|
||||
url: buildForwardedRequestUrl(request, forwardedUri),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid original request URI"),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -37,8 +37,3 @@ The stack includes the [Formbricks Hub](https://github.com/formbricks/hub) API (
|
||||
- **Development** (`docker-compose.dev.yml`): Hub uses the same local Postgres database and `HUB_API_KEY` defaults to `dev-api-key`. Cube is behind the `xm` profile, `CUBEJS_API_URL` defaults to `http://localhost:4000`, and `pnpm dev:setup` generates `CUBEJS_API_SECRET` in the repo root `.env`. The Hub image is pinned to a semver tag (`hub` and `hub-migrate` share the same value); override `HUB_IMAGE_TAG` in the repo root `.env` to test a specific Hub release.
|
||||
|
||||
In development, Hub is exposed locally on port **8080**. When the `xm` profile is enabled, Cube is exposed on **4000** (with the Cube playground on **4001**). In production Docker Compose, Hub stays internal to the compose network at `http://hub:8080`; Cube also stays internal at `http://cube:4000` when enabled.
|
||||
|
||||
The one-click Traefik installer exposes Hub-backed FeedbackRecords on the Formbricks origin at
|
||||
`/api/v3/feedbackRecords` and `/v1/feedback-records`. Traefik uses Formbricks gateway auth, rewrites the v3
|
||||
path to Hub's `/v1/feedback-records`, injects `Authorization: Bearer ${HUB_API_KEY}` for Hub, and strips client
|
||||
API key/cookie headers before the Hub hop.
|
||||
|
||||
+2
-65
@@ -397,12 +397,6 @@ EOF
|
||||
print " - \"traefik.http.routers.formbricks.tls=true\""
|
||||
print " - \"traefik.http.routers.formbricks.tls.certresolver=default\""
|
||||
print " - \"traefik.http.services.formbricks.loadbalancer.server.port=3000\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.rule=Host(`" domain_name "`) && Path(`/api/v3/feedbackRecords/token`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.entrypoints=websecure\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.tls=true\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.tls.certresolver=default\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.service=formbricks\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.priority=200\""
|
||||
if (hsts_enabled == "y") {
|
||||
print " - \"traefik.http.middlewares.hstsHeader.headers.stsSeconds=31536000\""
|
||||
print " - \"traefik.http.middlewares.hstsHeader.headers.forceSTSHeader=true\""
|
||||
@@ -411,10 +405,6 @@ EOF
|
||||
} else {
|
||||
print " - \"traefik.http.routers.formbricks_http.entrypoints=web\""
|
||||
print " - \"traefik.http.routers.formbricks_http.rule=Host(`" domain_name "`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-token-http.rule=Host(`" domain_name "`) && Path(`/api/v3/feedbackRecords/token`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-token-http.entrypoints=web\""
|
||||
print " - \"traefik.http.routers.feedback-records-token-http.service=formbricks\""
|
||||
print " - \"traefik.http.routers.feedback-records-token-http.priority=200\""
|
||||
}
|
||||
print $0
|
||||
} else {
|
||||
@@ -423,57 +413,6 @@ EOF
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' docker-compose.yml >tmp.yml && mv tmp.yml docker-compose.yml
|
||||
|
||||
# Step 1b: Add FeedbackRecords gateway labels to the Hub service.
|
||||
awk -v domain_name="$domain_name" -v hsts_enabled="$hsts_enabled" '
|
||||
BEGIN { in_hub = 0; inserted = 0 }
|
||||
/^ hub:/ { in_hub = 1 }
|
||||
in_hub && /^ [A-Za-z0-9_-]+:/ && !/^ hub:/ { in_hub = 0 }
|
||||
{
|
||||
if (in_hub && !inserted && $0 ~ /^ environment:/) {
|
||||
print " labels:"
|
||||
print " - \"traefik.enable=true\""
|
||||
print " - \"traefik.http.services.feedback-records-hub.loadbalancer.server.port=8080\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.rule=Host(`" domain_name "`) && PathPrefix(`/api/v3/feedbackRecords`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.entrypoints=websecure\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.tls=true\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.tls.certresolver=default\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.service=feedback-records-hub\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.priority=100\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.middlewares=feedback-records-auth,feedback-records-v3-rewrite,feedback-records-hub-headers\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.rule=Host(`" domain_name "`) && PathPrefix(`/v1/feedback-records`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.entrypoints=websecure\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.tls=true\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.tls.certresolver=default\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.service=feedback-records-hub\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.priority=100\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.middlewares=feedback-records-auth,feedback-records-hub-headers\""
|
||||
if (hsts_enabled != "y") {
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.rule=Host(`" domain_name "`) && PathPrefix(`/api/v3/feedbackRecords`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.entrypoints=web\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.service=feedback-records-hub\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.priority=100\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.middlewares=feedback-records-auth,feedback-records-v3-rewrite,feedback-records-hub-headers\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.rule=Host(`" domain_name "`) && PathPrefix(`/v1/feedback-records`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.entrypoints=web\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.service=feedback-records-hub\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.priority=100\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.middlewares=feedback-records-auth,feedback-records-hub-headers\""
|
||||
}
|
||||
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.address=http://formbricks:3000/api/traefik-auth/feedback-records\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.forwardbody=true\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.maxbodysize=1048576\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.preserverequestmethod=true\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-v3-rewrite.replacepathregex.regex=^/api/v3/feedbackRecords(.*)\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-v3-rewrite.replacepathregex.replacement=/v1/feedback-records$${1}\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-hub-headers.headers.customrequestheaders.Authorization=Bearer ${HUB_API_KEY}\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-hub-headers.headers.customrequestheaders.X-API-Key=\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-hub-headers.headers.customrequestheaders.Cookie=\""
|
||||
inserted = 1
|
||||
}
|
||||
print
|
||||
}
|
||||
' docker-compose.yml >tmp.yml && mv tmp.yml docker-compose.yml
|
||||
|
||||
# Step 2: Ensure formbricks waits for minio-init to complete successfully (mapping depends_on)
|
||||
@@ -572,12 +511,11 @@ EOF
|
||||
if [[ $insert_traefik == "y" ]]; then
|
||||
cat >> "$services_snippet_file" << EOF
|
||||
traefik:
|
||||
image: "traefik:v3.6.4"
|
||||
image: "traefik:v2.11.31"
|
||||
restart: always
|
||||
container_name: "traefik"
|
||||
depends_on:
|
||||
- formbricks
|
||||
- hub
|
||||
- minio
|
||||
ports:
|
||||
- "80:80"
|
||||
@@ -602,12 +540,11 @@ EOF
|
||||
cat > "$services_snippet_file" << EOF
|
||||
|
||||
traefik:
|
||||
image: "traefik:v3.6.4"
|
||||
image: "traefik:v2.11.31"
|
||||
restart: always
|
||||
container_name: "traefik"
|
||||
depends_on:
|
||||
- formbricks
|
||||
- hub
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
title: "API Gateway"
|
||||
description: "Gateway auth architecture for proxied service APIs"
|
||||
icon: "route"
|
||||
---
|
||||
|
||||
### Gateway Model
|
||||
|
||||
Formbricks gateway auth is split into three layers:
|
||||
|
||||
- A shared gateway-auth core authenticates the caller, normalizes the original request, and dispatches to a
|
||||
service authorizer.
|
||||
- Provider adapters translate ingress-specific auth requests into the shared shape. Envoy uses
|
||||
`/api/envoy-auth/[...path]`; Traefik uses `/api/traefik-auth/[...path]`.
|
||||
- Service authorizers own service-specific authorization. FeedbackRecords is the first registered service authorizer.
|
||||
|
||||
### Tokens
|
||||
|
||||
Session-authenticated browser callers should use `/api/v3/gateway/token` with
|
||||
`{ "service": "feedbackRecords" }`. The token identifies the user for the gateway only; every proxied request
|
||||
still runs through gateway authorization. `/api/v3/feedbackRecords/token` remains a compatibility alias.
|
||||
|
||||
### Provider Adapters
|
||||
|
||||
Envoy and Traefik do not send auth subrequests in the same format, so they stay as thin adapters. Envoy derives the
|
||||
original path from the auth request path. Traefik derives it from `X-Forwarded-Method` and `X-Forwarded-Uri`.
|
||||
Both adapters reuse the same gateway-auth core and FeedbackRecords authorizer.
|
||||
@@ -309,7 +309,6 @@
|
||||
"group": "Technical Handbook",
|
||||
"pages": [
|
||||
"development/technical-handbook/overview",
|
||||
"development/technical-handbook/api-gateway",
|
||||
"development/technical-handbook/background-job-processing",
|
||||
"development/technical-handbook/cube-tenant-isolation",
|
||||
"development/technical-handbook/database-model",
|
||||
|
||||
@@ -109,7 +109,7 @@ Modify the configuration to enforce SSL. The rest of the configuration should re
|
||||
<<: *environment
|
||||
|
||||
traefik:
|
||||
image: "traefik:v3.6.4"
|
||||
image: "traefik:v2.11.31"
|
||||
restart: always
|
||||
container_name: "traefik"
|
||||
depends_on:
|
||||
@@ -145,4 +145,5 @@ Modify the configuration to enforce SSL. The rest of the configuration should re
|
||||
|
||||
This setup ensures that Formbricks securely communicates using your own SSL certificate. 🚀
|
||||
|
||||
If you have any questions or require help, feel free to reach out to us on [**GitHub Discussions**](https://github.com/formbricks/formbricks/discussions). 😃
|
||||
If you have any questions or require help, feel free to reach out to us on [**GitHub Discussions**](https://github.com/formbricks/formbricks/discussions). 😃[
|
||||
](https://formbricks.com/docs/developer-docs/rest-api)
|
||||
@@ -151,13 +151,6 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
|
||||
enabled, Cube.js is internal too. The app reaches them through `http://hub:8080` and `http://cube:4000`.
|
||||
</Note>
|
||||
|
||||
<Info>
|
||||
If you use the one-click Traefik setup, FeedbackRecords are available on the Formbricks origin at
|
||||
`/api/v3/feedbackRecords` and `/v1/feedback-records`. Custom Docker reverse proxies need equivalent wiring:
|
||||
run gateway auth against the Formbricks app, rewrite `/api/v3/feedbackRecords` to Hub's
|
||||
`/v1/feedback-records`, and inject `Authorization: Bearer <HUB_API_KEY>` only on the Hub-bound hop.
|
||||
</Info>
|
||||
|
||||
## Update
|
||||
|
||||
Please take a look at our [migration guide](/self-hosting/advanced/migration) for version specific steps to update Formbricks.
|
||||
|
||||
@@ -40,13 +40,6 @@ curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/stable/docker
|
||||
finishes, add it manually. `HUB_API_URL` should normally stay at `http://hub:8080`.
|
||||
</Info>
|
||||
|
||||
<Info>
|
||||
The v5 one-click Traefik setup also exposes Hub-backed FeedbackRecords through Formbricks at
|
||||
`/api/v3/feedbackRecords` and `/v1/feedback-records`. Traefik calls the internal Formbricks gateway auth
|
||||
endpoint first, then forwards allowed requests to Hub with the generated `HUB_API_KEY`. Browser callers should
|
||||
request short-lived gateway tokens from `/api/v3/gateway/token` with `{ "service": "feedbackRecords" }`.
|
||||
</Info>
|
||||
|
||||
### Script Prompts
|
||||
|
||||
During installation, the script will prompt you to provide some details:
|
||||
|
||||
Reference in New Issue
Block a user