mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-25 16:00:16 -06:00
Compare commits
9 Commits
4.0.0-rc.2
...
4.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d49517be91 | ||
|
|
7aedb73378 | ||
|
|
4112722a88 | ||
|
|
0eddeb46c1 | ||
|
|
774f45b109 | ||
|
|
3c65c002bb | ||
|
|
65539e85df | ||
|
|
91dab12a81 | ||
|
|
1c5244e030 |
6
.github/workflows/move-stable-tag.yml
vendored
6
.github/workflows/move-stable-tag.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "The release tag name (e.g., v1.2.3)"
|
||||
description: "The release tag name (e.g., 1.2.3)"
|
||||
required: true
|
||||
type: string
|
||||
commit_sha:
|
||||
@@ -53,8 +53,8 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
# Validate release tag format
|
||||
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "❌ Error: Invalid release tag format. Expected format: v1.2.3, v1.2.3-alpha"
|
||||
if [[ ! "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "❌ Error: Invalid release tag format. Expected format: 1.2.3, 1.2.3-alpha"
|
||||
echo "Provided: $RELEASE_TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -117,7 +117,7 @@ export const MAX_FILE_UPLOAD_SIZES = {
|
||||
// Storage is considered configured if we have the minimum required settings:
|
||||
// - S3_REGION and S3_BUCKET_NAME are always required
|
||||
// - S3_ACCESS_KEY and S3_SECRET_KEY are optional (for IAM role-based authentication)
|
||||
export const IS_STORAGE_CONFIGURED = Boolean(S3_REGION && S3_BUCKET_NAME);
|
||||
export const IS_STORAGE_CONFIGURED = Boolean(S3_BUCKET_NAME);
|
||||
|
||||
// Colors for Survey Bg
|
||||
export const SURVEY_BG_COLORS = [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { env } from "@/lib/env";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import * as crypto from "@/lib/crypto";
|
||||
import {
|
||||
createEmailChangeToken,
|
||||
createEmailToken,
|
||||
@@ -14,12 +15,69 @@ import {
|
||||
verifyTokenForLinkSurvey,
|
||||
} from "./jwt";
|
||||
|
||||
const TEST_ENCRYPTION_KEY = "0".repeat(32); // 32-byte key for AES-256-GCM
|
||||
const TEST_NEXTAUTH_SECRET = "test-nextauth-secret";
|
||||
const DIFFERENT_SECRET = "different-secret";
|
||||
|
||||
// Error message constants
|
||||
const NEXTAUTH_SECRET_ERROR = "NEXTAUTH_SECRET is not set";
|
||||
const ENCRYPTION_KEY_ERROR = "ENCRYPTION_KEY is not set";
|
||||
|
||||
// Helper function to test error cases for missing secrets/keys
|
||||
const testMissingSecretsError = async (
|
||||
testFn: (...args: any[]) => any,
|
||||
args: any[],
|
||||
options: {
|
||||
testNextAuthSecret?: boolean;
|
||||
testEncryptionKey?: boolean;
|
||||
isAsync?: boolean;
|
||||
} = {}
|
||||
) => {
|
||||
const { testNextAuthSecret = true, testEncryptionKey = true, isAsync = false } = options;
|
||||
|
||||
if (testNextAuthSecret) {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalSecret = (constants as any).NEXTAUTH_SECRET;
|
||||
(constants as any).NEXTAUTH_SECRET = undefined;
|
||||
|
||||
if (isAsync) {
|
||||
await expect(testFn(...args)).rejects.toThrow(NEXTAUTH_SECRET_ERROR);
|
||||
} else {
|
||||
expect(() => testFn(...args)).toThrow(NEXTAUTH_SECRET_ERROR);
|
||||
}
|
||||
|
||||
// Restore
|
||||
(constants as any).NEXTAUTH_SECRET = originalSecret;
|
||||
}
|
||||
|
||||
if (testEncryptionKey) {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalKey = (constants as any).ENCRYPTION_KEY;
|
||||
(constants as any).ENCRYPTION_KEY = undefined;
|
||||
|
||||
if (isAsync) {
|
||||
await expect(testFn(...args)).rejects.toThrow(ENCRYPTION_KEY_ERROR);
|
||||
} else {
|
||||
expect(() => testFn(...args)).toThrow(ENCRYPTION_KEY_ERROR);
|
||||
}
|
||||
|
||||
// Restore
|
||||
(constants as any).ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
};
|
||||
|
||||
// Mock environment variables
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENCRYPTION_KEY: "0".repeat(32), // 32-byte key for AES-256-GCM
|
||||
ENCRYPTION_KEY: "0".repeat(32),
|
||||
NEXTAUTH_SECRET: "test-nextauth-secret",
|
||||
} as typeof env,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock constants
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
NEXTAUTH_SECRET: "test-nextauth-secret",
|
||||
ENCRYPTION_KEY: "0".repeat(32),
|
||||
}));
|
||||
|
||||
// Mock prisma
|
||||
@@ -31,22 +89,65 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
describe("JWT Functions", () => {
|
||||
// Mock logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
const mockUser = {
|
||||
id: "test-user-id",
|
||||
email: "test@example.com",
|
||||
};
|
||||
|
||||
let mockSymmetricEncrypt: any;
|
||||
let mockSymmetricDecrypt: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup default crypto mocks
|
||||
mockSymmetricEncrypt = vi
|
||||
.spyOn(crypto, "symmetricEncrypt")
|
||||
.mockImplementation((text: string) => `encrypted_${text}`);
|
||||
|
||||
mockSymmetricDecrypt = vi
|
||||
.spyOn(crypto, "symmetricDecrypt")
|
||||
.mockImplementation((encryptedText: string) => encryptedText.replace("encrypted_", ""));
|
||||
|
||||
(prisma.user.findUnique as any).mockResolvedValue(mockUser);
|
||||
});
|
||||
|
||||
describe("createToken", () => {
|
||||
test("should create a valid token", () => {
|
||||
const token = createToken(mockUser.id, mockUser.email);
|
||||
test("should create a valid token with encrypted user ID", () => {
|
||||
const token = createToken(mockUser.id);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.id, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should accept custom options", () => {
|
||||
const customOptions = { expiresIn: "1h" };
|
||||
const token = createToken(mockUser.id, customOptions);
|
||||
expect(token).toBeDefined();
|
||||
|
||||
// Verify the token contains the expected expiration
|
||||
const decoded = jwt.decode(token) as any;
|
||||
expect(decoded.exp).toBeDefined();
|
||||
expect(decoded.iat).toBeDefined();
|
||||
// Should expire in approximately 1 hour (3600 seconds)
|
||||
expect(decoded.exp - decoded.iat).toBe(3600);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET is not set", async () => {
|
||||
await testMissingSecretsError(createToken, [mockUser.id], {
|
||||
testNextAuthSecret: true,
|
||||
testEncryptionKey: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +157,18 @@ describe("JWT Functions", () => {
|
||||
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should include surveyId in payload", () => {
|
||||
const surveyId = "test-survey-id";
|
||||
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
|
||||
const decoded = jwt.decode(token) as any;
|
||||
expect(decoded.surveyId).toBe(surveyId);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(createTokenForLinkSurvey, ["survey-id", mockUser.email]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,24 +177,30 @@ describe("JWT Functions", () => {
|
||||
const token = createEmailToken(mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET is not set", () => {
|
||||
const originalSecret = env.NEXTAUTH_SECRET;
|
||||
try {
|
||||
(env as any).NEXTAUTH_SECRET = undefined;
|
||||
expect(() => createEmailToken(mockUser.email)).toThrow("NEXTAUTH_SECRET is not set");
|
||||
} finally {
|
||||
(env as any).NEXTAUTH_SECRET = originalSecret;
|
||||
}
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(createEmailToken, [mockUser.email]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEmailFromEmailToken", () => {
|
||||
test("should extract email from valid token", () => {
|
||||
const token = createEmailToken(mockUser.email);
|
||||
const extractedEmail = getEmailFromEmailToken(token);
|
||||
expect(extractedEmail).toBe(mockUser.email);
|
||||
describe("createEmailChangeToken", () => {
|
||||
test("should create a valid email change token with 1 day expiration", () => {
|
||||
const token = createEmailChangeToken(mockUser.id, mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.id, TEST_ENCRYPTION_KEY);
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
|
||||
|
||||
const decoded = jwt.decode(token) as any;
|
||||
expect(decoded.exp).toBeDefined();
|
||||
expect(decoded.iat).toBeDefined();
|
||||
// Should expire in approximately 1 day (86400 seconds)
|
||||
expect(decoded.exp - decoded.iat).toBe(86400);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(createEmailChangeToken, [mockUser.id, mockUser.email]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,6 +210,50 @@ describe("JWT Functions", () => {
|
||||
const token = createInviteToken(inviteId, mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(inviteId, TEST_ENCRYPTION_KEY);
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should accept custom options", () => {
|
||||
const inviteId = "test-invite-id";
|
||||
const customOptions = { expiresIn: "24h" };
|
||||
const token = createInviteToken(inviteId, mockUser.email, customOptions);
|
||||
expect(token).toBeDefined();
|
||||
|
||||
const decoded = jwt.decode(token) as any;
|
||||
expect(decoded.exp).toBeDefined();
|
||||
expect(decoded.iat).toBeDefined();
|
||||
// Should expire in approximately 24 hours (86400 seconds)
|
||||
expect(decoded.exp - decoded.iat).toBe(86400);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(createInviteToken, ["invite-id", mockUser.email]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEmailFromEmailToken", () => {
|
||||
test("should extract email from valid token", () => {
|
||||
const token = createEmailToken(mockUser.email);
|
||||
const extractedEmail = getEmailFromEmailToken(token);
|
||||
expect(extractedEmail).toBe(mockUser.email);
|
||||
expect(mockSymmetricDecrypt).toHaveBeenCalledWith(`encrypted_${mockUser.email}`, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should fall back to original email if decryption fails", () => {
|
||||
mockSymmetricDecrypt.mockImplementationOnce(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
// Create token manually with unencrypted email for legacy compatibility
|
||||
const legacyToken = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
|
||||
const extractedEmail = getEmailFromEmailToken(legacyToken);
|
||||
expect(extractedEmail).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
const token = jwt.sign({ email: "test@example.com" }, TEST_NEXTAUTH_SECRET);
|
||||
await testMissingSecretsError(getEmailFromEmailToken, [token]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,23 +269,194 @@ describe("JWT Functions", () => {
|
||||
const result = verifyTokenForLinkSurvey("invalid-token", "test-survey-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null if NEXTAUTH_SECRET is not set", async () => {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalSecret = (constants as any).NEXTAUTH_SECRET;
|
||||
(constants as any).NEXTAUTH_SECRET = undefined;
|
||||
|
||||
const result = verifyTokenForLinkSurvey("any-token", "test-survey-id");
|
||||
expect(result).toBeNull();
|
||||
|
||||
// Restore
|
||||
(constants as any).NEXTAUTH_SECRET = originalSecret;
|
||||
});
|
||||
|
||||
test("should return null if surveyId doesn't match", () => {
|
||||
const surveyId = "test-survey-id";
|
||||
const differentSurveyId = "different-survey-id";
|
||||
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
|
||||
const result = verifyTokenForLinkSurvey(token, differentSurveyId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null if email is missing from payload", () => {
|
||||
const tokenWithoutEmail = jwt.sign({ surveyId: "test-survey-id" }, TEST_NEXTAUTH_SECRET);
|
||||
const result = verifyTokenForLinkSurvey(tokenWithoutEmail, "test-survey-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should fall back to original email if decryption fails", () => {
|
||||
mockSymmetricDecrypt.mockImplementationOnce(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
// Create legacy token with unencrypted email
|
||||
const legacyToken = jwt.sign(
|
||||
{
|
||||
email: mockUser.email,
|
||||
surveyId: "test-survey-id",
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(legacyToken, "test-survey-id");
|
||||
expect(result).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should fall back to original email if ENCRYPTION_KEY is not set", async () => {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalKey = (constants as any).ENCRYPTION_KEY;
|
||||
(constants as any).ENCRYPTION_KEY = undefined;
|
||||
|
||||
// Create a token with unencrypted email (as it would be if ENCRYPTION_KEY was not set during creation)
|
||||
const token = jwt.sign(
|
||||
{
|
||||
email: mockUser.email,
|
||||
surveyId: "survey-id",
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(token, "survey-id");
|
||||
expect(result).toBe(mockUser.email);
|
||||
|
||||
// Restore
|
||||
(constants as any).ENCRYPTION_KEY = originalKey;
|
||||
});
|
||||
|
||||
test("should verify legacy survey tokens with surveyId-based secret", async () => {
|
||||
const surveyId = "test-survey-id";
|
||||
|
||||
// Create legacy token with old format (NEXTAUTH_SECRET + surveyId)
|
||||
const legacyToken = jwt.sign({ email: `encrypted_${mockUser.email}` }, TEST_NEXTAUTH_SECRET + surveyId);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(legacyToken, surveyId);
|
||||
expect(result).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should reject survey tokens that fail both new and legacy verification", async () => {
|
||||
const surveyId = "test-survey-id";
|
||||
const invalidToken = jwt.sign({ email: "encrypted_test@example.com" }, "wrong-secret");
|
||||
|
||||
const result = verifyTokenForLinkSurvey(invalidToken, surveyId);
|
||||
expect(result).toBeNull();
|
||||
|
||||
// Verify error logging
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.any(Error), "Survey link token verification failed");
|
||||
});
|
||||
|
||||
test("should reject legacy survey tokens for wrong survey", () => {
|
||||
const correctSurveyId = "correct-survey-id";
|
||||
const wrongSurveyId = "wrong-survey-id";
|
||||
|
||||
// Create legacy token for one survey
|
||||
const legacyToken = jwt.sign(
|
||||
{ email: `encrypted_${mockUser.email}` },
|
||||
TEST_NEXTAUTH_SECRET + correctSurveyId
|
||||
);
|
||||
|
||||
// Try to verify with different survey ID
|
||||
const result = verifyTokenForLinkSurvey(legacyToken, wrongSurveyId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyToken", () => {
|
||||
test("should verify valid token", async () => {
|
||||
const token = createToken(mockUser.id, mockUser.email);
|
||||
const token = createToken(mockUser.id);
|
||||
const verified = await verifyToken(token);
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id,
|
||||
id: mockUser.id, // Returns the decrypted user ID
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error if user not found", async () => {
|
||||
(prisma.user.findUnique as any).mockResolvedValue(null);
|
||||
const token = createToken(mockUser.id, mockUser.email);
|
||||
const token = createToken(mockUser.id);
|
||||
await expect(verifyToken(token)).rejects.toThrow("User not found");
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET is not set", async () => {
|
||||
await testMissingSecretsError(verifyToken, ["any-token"], {
|
||||
testNextAuthSecret: true,
|
||||
testEncryptionKey: false,
|
||||
isAsync: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error for invalid token signature", async () => {
|
||||
const invalidToken = jwt.sign({ id: "test-id" }, DIFFERENT_SECRET);
|
||||
await expect(verifyToken(invalidToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should throw error if token payload is missing id", async () => {
|
||||
const tokenWithoutId = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
|
||||
await expect(verifyToken(tokenWithoutId)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should return raw id from payload", async () => {
|
||||
// Create token with unencrypted id
|
||||
const token = jwt.sign({ id: mockUser.id }, TEST_NEXTAUTH_SECRET);
|
||||
const verified = await verifyToken(token);
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id, // Returns the raw ID from payload
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
|
||||
test("should verify legacy tokens with email-based secret", async () => {
|
||||
// Create legacy token with old format (NEXTAUTH_SECRET + userEmail)
|
||||
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET + mockUser.email);
|
||||
|
||||
const verified = await verifyToken(legacyToken);
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id, // Returns the decrypted user ID
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
|
||||
test("should prioritize new tokens over legacy tokens", async () => {
|
||||
// Create both new and legacy tokens for the same user
|
||||
const newToken = createToken(mockUser.id);
|
||||
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET + mockUser.email);
|
||||
|
||||
// New token should verify without triggering legacy path
|
||||
const verifiedNew = await verifyToken(newToken);
|
||||
expect(verifiedNew.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
|
||||
// Legacy token should trigger legacy path
|
||||
const verifiedLegacy = await verifyToken(legacyToken);
|
||||
expect(verifiedLegacy.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
});
|
||||
|
||||
test("should reject tokens that fail both new and legacy verification", async () => {
|
||||
const invalidToken = jwt.sign({ id: "encrypted_test-id" }, "wrong-secret");
|
||||
await expect(verifyToken(invalidToken)).rejects.toThrow("Invalid token");
|
||||
|
||||
// Verify both methods were attempted
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Token verification failed with new method"
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Token verification failed with legacy method"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyInviteToken", () => {
|
||||
@@ -139,6 +473,53 @@ describe("JWT Functions", () => {
|
||||
test("should throw error for invalid token", () => {
|
||||
expect(() => verifyInviteToken("invalid-token")).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(verifyInviteToken, ["any-token"]);
|
||||
});
|
||||
|
||||
test("should throw error if inviteId is missing", () => {
|
||||
const tokenWithoutInviteId = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
|
||||
expect(() => verifyInviteToken(tokenWithoutInviteId)).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
|
||||
test("should throw error if email is missing", () => {
|
||||
const tokenWithoutEmail = jwt.sign({ inviteId: "test-invite-id" }, TEST_NEXTAUTH_SECRET);
|
||||
expect(() => verifyInviteToken(tokenWithoutEmail)).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
|
||||
test("should fall back to original values if decryption fails", () => {
|
||||
mockSymmetricDecrypt.mockImplementation(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
const inviteId = "test-invite-id";
|
||||
const legacyToken = jwt.sign(
|
||||
{
|
||||
inviteId,
|
||||
email: mockUser.email,
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const verified = verifyInviteToken(legacyToken);
|
||||
expect(verified).toEqual({
|
||||
inviteId,
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error for token with wrong signature", () => {
|
||||
const invalidToken = jwt.sign(
|
||||
{
|
||||
inviteId: "test-invite-id",
|
||||
email: mockUser.email,
|
||||
},
|
||||
DIFFERENT_SECRET
|
||||
);
|
||||
|
||||
expect(() => verifyInviteToken(invalidToken)).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyEmailChangeToken", () => {
|
||||
@@ -150,22 +531,478 @@ describe("JWT Functions", () => {
|
||||
expect(result).toEqual({ id: userId, email });
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(verifyEmailChangeToken, ["any-token"], { isAsync: true });
|
||||
});
|
||||
|
||||
test("should throw error if token is invalid or missing fields", async () => {
|
||||
// Create a token with missing fields
|
||||
const jwt = await import("jsonwebtoken");
|
||||
const token = jwt.sign({ foo: "bar" }, env.NEXTAUTH_SECRET as string);
|
||||
const token = jwt.sign({ foo: "bar" }, TEST_NEXTAUTH_SECRET);
|
||||
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
|
||||
"Token is invalid or missing required fields"
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error if id is missing", async () => {
|
||||
const token = jwt.sign({ email: "test@example.com" }, TEST_NEXTAUTH_SECRET);
|
||||
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
|
||||
"Token is invalid or missing required fields"
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error if email is missing", async () => {
|
||||
const token = jwt.sign({ id: "test-id" }, TEST_NEXTAUTH_SECRET);
|
||||
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
|
||||
"Token is invalid or missing required fields"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return original id/email if decryption fails", async () => {
|
||||
// Create a token with non-encrypted id/email
|
||||
const jwt = await import("jsonwebtoken");
|
||||
mockSymmetricDecrypt.mockImplementation(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
const payload = { id: "plain-id", email: "plain@example.com" };
|
||||
const token = jwt.sign(payload, env.NEXTAUTH_SECRET as string);
|
||||
const token = jwt.sign(payload, TEST_NEXTAUTH_SECRET);
|
||||
const result = await verifyEmailChangeToken(token);
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
test("should throw error for token with wrong signature", async () => {
|
||||
const invalidToken = jwt.sign(
|
||||
{
|
||||
id: "test-id",
|
||||
email: "test@example.com",
|
||||
},
|
||||
DIFFERENT_SECRET
|
||||
);
|
||||
|
||||
await expect(verifyEmailChangeToken(invalidToken)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// SECURITY SCENARIO TESTS
|
||||
describe("Security Scenarios", () => {
|
||||
describe("Algorithm Confusion Attack Prevention", () => {
|
||||
test("should reject 'none' algorithm tokens in verifyToken", async () => {
|
||||
// Create malicious token with "none" algorithm
|
||||
const maliciousToken =
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url") +
|
||||
"." +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
id: "encrypted_malicious-id",
|
||||
})
|
||||
).toString("base64url") +
|
||||
".";
|
||||
|
||||
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should reject 'none' algorithm tokens in verifyTokenForLinkSurvey", () => {
|
||||
const maliciousToken =
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url") +
|
||||
"." +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
email: "encrypted_attacker@evil.com",
|
||||
surveyId: "test-survey-id",
|
||||
})
|
||||
).toString("base64url") +
|
||||
".";
|
||||
|
||||
const result = verifyTokenForLinkSurvey(maliciousToken, "test-survey-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should reject 'none' algorithm tokens in verifyInviteToken", () => {
|
||||
const maliciousToken =
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url") +
|
||||
"." +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
inviteId: "encrypted_malicious-invite",
|
||||
email: "encrypted_attacker@evil.com",
|
||||
})
|
||||
).toString("base64url") +
|
||||
".";
|
||||
|
||||
expect(() => verifyInviteToken(maliciousToken)).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
|
||||
test("should reject 'none' algorithm tokens in verifyEmailChangeToken", async () => {
|
||||
const maliciousToken =
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url") +
|
||||
"." +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
id: "encrypted_malicious-id",
|
||||
email: "encrypted_attacker@evil.com",
|
||||
})
|
||||
).toString("base64url") +
|
||||
".";
|
||||
|
||||
await expect(verifyEmailChangeToken(maliciousToken)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should reject RS256 algorithm tokens (HS256/RS256 confusion)", async () => {
|
||||
// Create malicious token with RS256 algorithm header but HS256 signature
|
||||
const maliciousHeader = Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "RS256",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url");
|
||||
|
||||
const maliciousPayload = Buffer.from(
|
||||
JSON.stringify({
|
||||
id: "encrypted_malicious-id",
|
||||
})
|
||||
).toString("base64url");
|
||||
|
||||
// Create signature using HMAC (as if it were HS256)
|
||||
const crypto = require("crypto");
|
||||
const signature = crypto
|
||||
.createHmac("sha256", TEST_NEXTAUTH_SECRET)
|
||||
.update(`${maliciousHeader}.${maliciousPayload}`)
|
||||
.digest("base64url");
|
||||
|
||||
const maliciousToken = `${maliciousHeader}.${maliciousPayload}.${signature}`;
|
||||
|
||||
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should only accept HS256 algorithm", async () => {
|
||||
// Test that other valid algorithms are rejected
|
||||
const otherAlgorithms = ["HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512"];
|
||||
|
||||
for (const alg of otherAlgorithms) {
|
||||
const maliciousHeader = Buffer.from(
|
||||
JSON.stringify({
|
||||
alg,
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url");
|
||||
|
||||
const maliciousPayload = Buffer.from(
|
||||
JSON.stringify({
|
||||
id: "encrypted_test-id",
|
||||
})
|
||||
).toString("base64url");
|
||||
|
||||
const maliciousToken = `${maliciousHeader}.${maliciousPayload}.fake-signature`;
|
||||
|
||||
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Tampering", () => {
|
||||
test("should reject tokens with modified payload", async () => {
|
||||
const token = createToken(mockUser.id);
|
||||
const [header, payload, signature] = token.split(".");
|
||||
|
||||
// Modify the payload
|
||||
const decodedPayload = JSON.parse(Buffer.from(payload, "base64url").toString());
|
||||
decodedPayload.id = "malicious-id";
|
||||
const tamperedPayload = Buffer.from(JSON.stringify(decodedPayload)).toString("base64url");
|
||||
const tamperedToken = `${header}.${tamperedPayload}.${signature}`;
|
||||
|
||||
await expect(verifyToken(tamperedToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should reject tokens with modified signature", async () => {
|
||||
const token = createToken(mockUser.id);
|
||||
const [header, payload] = token.split(".");
|
||||
const tamperedToken = `${header}.${payload}.tamperedsignature`;
|
||||
|
||||
await expect(verifyToken(tamperedToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should reject malformed tokens", async () => {
|
||||
const malformedTokens = [
|
||||
"not.a.jwt",
|
||||
"only.two.parts",
|
||||
"too.many.parts.here.invalid",
|
||||
"",
|
||||
"invalid-base64",
|
||||
];
|
||||
|
||||
for (const malformedToken of malformedTokens) {
|
||||
await expect(verifyToken(malformedToken)).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cross-Survey Token Reuse", () => {
|
||||
test("should reject survey tokens used for different surveys", () => {
|
||||
const surveyId1 = "survey-1";
|
||||
const surveyId2 = "survey-2";
|
||||
|
||||
const token = createTokenForLinkSurvey(surveyId1, mockUser.email);
|
||||
const result = verifyTokenForLinkSurvey(token, surveyId2);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Expired Tokens", () => {
|
||||
test("should reject expired tokens", async () => {
|
||||
const expiredToken = jwt.sign(
|
||||
{
|
||||
id: "encrypted_test-id",
|
||||
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
await expect(verifyToken(expiredToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should reject expired email change tokens", async () => {
|
||||
const expiredToken = jwt.sign(
|
||||
{
|
||||
id: "encrypted_test-id",
|
||||
email: "encrypted_test@example.com",
|
||||
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
await expect(verifyEmailChangeToken(expiredToken)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Encryption Key Attacks", () => {
|
||||
test("should fail gracefully with wrong encryption key", async () => {
|
||||
mockSymmetricDecrypt.mockImplementation(() => {
|
||||
throw new Error("Authentication tag verification failed");
|
||||
});
|
||||
|
||||
// Mock findUnique to only return user for correct decrypted ID, not ciphertext
|
||||
(prisma.user.findUnique as any).mockImplementation(({ where }: { where: { id: string } }) => {
|
||||
if (where.id === mockUser.id) {
|
||||
return Promise.resolve(mockUser);
|
||||
}
|
||||
return Promise.resolve(null); // Return null for ciphertext IDs
|
||||
});
|
||||
|
||||
const token = createToken(mockUser.id);
|
||||
// Should fail because ciphertext passed as userId won't match any user in DB
|
||||
await expect(verifyToken(token)).rejects.toThrow(/User not found/i);
|
||||
});
|
||||
|
||||
test("should handle encryption key not set gracefully", async () => {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalKey = (constants as any).ENCRYPTION_KEY;
|
||||
(constants as any).ENCRYPTION_KEY = undefined;
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
email: "test@example.com",
|
||||
surveyId: "test-survey-id",
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(token, "test-survey-id");
|
||||
expect(result).toBe("test@example.com");
|
||||
|
||||
// Restore
|
||||
(constants as any).ENCRYPTION_KEY = originalKey;
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQL Injection Attempts", () => {
|
||||
test("should safely handle malicious user IDs", async () => {
|
||||
const maliciousIds = [
|
||||
"'; DROP TABLE users; --",
|
||||
"1' OR '1'='1",
|
||||
"admin'/*",
|
||||
"<script>alert('xss')</script>",
|
||||
"../../etc/passwd",
|
||||
];
|
||||
|
||||
for (const maliciousId of maliciousIds) {
|
||||
mockSymmetricDecrypt.mockReturnValueOnce(maliciousId);
|
||||
|
||||
const token = jwt.sign({ id: "encrypted_malicious" }, TEST_NEXTAUTH_SECRET);
|
||||
|
||||
// The function should look up the user safely
|
||||
await verifyToken(token);
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: maliciousId },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Reuse and Replay Attacks", () => {
|
||||
test("should allow legitimate token reuse within validity period", async () => {
|
||||
const token = createToken(mockUser.id);
|
||||
|
||||
// First use
|
||||
const result1 = await verifyToken(token);
|
||||
expect(result1.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
|
||||
// Second use (should still work)
|
||||
const result2 = await verifyToken(token);
|
||||
expect(result2.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
});
|
||||
});
|
||||
|
||||
describe("Legacy Token Compatibility", () => {
|
||||
test("should handle legacy unencrypted tokens gracefully", async () => {
|
||||
// Legacy token with plain text data
|
||||
const legacyToken = jwt.sign({ id: mockUser.id }, TEST_NEXTAUTH_SECRET);
|
||||
const result = await verifyToken(legacyToken);
|
||||
|
||||
expect(result.id).toBe(mockUser.id); // Returns raw ID from payload
|
||||
expect(result.email).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should handle mixed encrypted/unencrypted fields", async () => {
|
||||
mockSymmetricDecrypt
|
||||
.mockImplementationOnce(() => mockUser.id) // id decrypts successfully
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("Email not encrypted");
|
||||
}); // email fails
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: "encrypted_test-id",
|
||||
email: "plain-email@example.com",
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const result = await verifyEmailChangeToken(token);
|
||||
expect(result.id).toBe(mockUser.id);
|
||||
expect(result.email).toBe("plain-email@example.com");
|
||||
});
|
||||
|
||||
test("should verify old format user tokens with email-based secrets", async () => {
|
||||
// Simulate old token format with per-user secret
|
||||
const oldFormatToken = jwt.sign(
|
||||
{ id: `encrypted_${mockUser.id}` },
|
||||
TEST_NEXTAUTH_SECRET + mockUser.email
|
||||
);
|
||||
|
||||
const result = await verifyToken(oldFormatToken);
|
||||
expect(result.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
expect(result.email).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should verify old format survey tokens with survey-based secrets", () => {
|
||||
const surveyId = "legacy-survey-id";
|
||||
|
||||
// Simulate old survey token format
|
||||
const oldFormatSurveyToken = jwt.sign(
|
||||
{ email: `encrypted_${mockUser.email}` },
|
||||
TEST_NEXTAUTH_SECRET + surveyId
|
||||
);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(oldFormatSurveyToken, surveyId);
|
||||
expect(result).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should gracefully handle database errors during legacy verification", async () => {
|
||||
// Create token that will fail new method
|
||||
const legacyToken = jwt.sign(
|
||||
{ id: `encrypted_${mockUser.id}` },
|
||||
TEST_NEXTAUTH_SECRET + mockUser.email
|
||||
);
|
||||
|
||||
// Make database lookup fail
|
||||
(prisma.user.findUnique as any).mockRejectedValueOnce(new Error("DB connection lost"));
|
||||
|
||||
await expect(verifyToken(legacyToken)).rejects.toThrow("DB connection lost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases and Error Handling", () => {
|
||||
test("should handle database connection errors gracefully", async () => {
|
||||
(prisma.user.findUnique as any).mockRejectedValue(new Error("Database connection failed"));
|
||||
|
||||
const token = createToken(mockUser.id);
|
||||
await expect(verifyToken(token)).rejects.toThrow("Database connection failed");
|
||||
});
|
||||
|
||||
test("should handle crypto module errors", () => {
|
||||
mockSymmetricEncrypt.mockImplementation(() => {
|
||||
throw new Error("Crypto module error");
|
||||
});
|
||||
|
||||
expect(() => createToken(mockUser.id)).toThrow("Crypto module error");
|
||||
});
|
||||
|
||||
test("should validate email format in tokens", () => {
|
||||
const invalidEmails = ["", "not-an-email", "missing@", "@missing-local.com", "spaces in@email.com"];
|
||||
|
||||
invalidEmails.forEach((invalidEmail) => {
|
||||
expect(() => createEmailToken(invalidEmail)).not.toThrow();
|
||||
// Note: JWT functions don't validate email format, they just encrypt/decrypt
|
||||
// Email validation should happen at a higher level
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle extremely long inputs", () => {
|
||||
const longString = "a".repeat(10000);
|
||||
|
||||
expect(() => createToken(longString)).not.toThrow();
|
||||
expect(() => createEmailToken(longString)).not.toThrow();
|
||||
});
|
||||
|
||||
test("should handle special characters in user data", () => {
|
||||
const specialChars = "!@#$%^&*()_+-=[]{}|;:'\",.<>?/~`";
|
||||
|
||||
expect(() => createToken(specialChars)).not.toThrow();
|
||||
expect(() => createEmailToken(specialChars)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance and Resource Exhaustion", () => {
|
||||
test("should handle rapid token creation without memory leaks", () => {
|
||||
const tokens: string[] = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
tokens.push(createToken(`user-${i}`));
|
||||
}
|
||||
|
||||
expect(tokens.length).toBe(1000);
|
||||
expect(tokens.every((token) => typeof token === "string")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle rapid token verification", async () => {
|
||||
const token = createToken(mockUser.id);
|
||||
|
||||
const verifications: Promise<any>[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
verifications.push(verifyToken(token));
|
||||
}
|
||||
|
||||
const results = await Promise.all(verifications);
|
||||
expect(results.length).toBe(100);
|
||||
expect(results.every((result: any) => result.id === mockUser.id)).toBe(true); // Returns decrypted user ID
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,43 +1,64 @@
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
import { env } from "@/lib/env";
|
||||
import jwt, { JwtPayload } from "jsonwebtoken";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
|
||||
export const createToken = (userId: string, userEmail: string, options = {}): string => {
|
||||
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ id: encryptedUserId }, env.NEXTAUTH_SECRET + userEmail, options);
|
||||
};
|
||||
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
|
||||
const encryptedEmail = symmetricEncrypt(userEmail, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET + surveyId);
|
||||
// Helper function to decrypt with fallback to plain text
|
||||
const decryptWithFallback = (encryptedText: string, key: string): string => {
|
||||
try {
|
||||
return symmetricDecrypt(encryptedText, key);
|
||||
} catch {
|
||||
return encryptedText; // Return as-is if decryption fails (legacy format)
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
export const createToken = (userId: string, options = {}): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as { id: string; email: string };
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
|
||||
return jwt.sign({ id: encryptedUserId }, NEXTAUTH_SECRET, options);
|
||||
};
|
||||
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedEmail = symmetricEncrypt(userEmail, ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail, surveyId }, NEXTAUTH_SECRET);
|
||||
};
|
||||
|
||||
export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
if (!payload?.id || !payload?.email) {
|
||||
throw new Error("Token is invalid or missing required fields");
|
||||
}
|
||||
|
||||
let decryptedId: string;
|
||||
let decryptedEmail: string;
|
||||
|
||||
try {
|
||||
decryptedId = symmetricDecrypt(payload.id, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
decryptedId = payload.id;
|
||||
}
|
||||
|
||||
try {
|
||||
decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
decryptedEmail = payload.email;
|
||||
}
|
||||
// Decrypt both fields with fallback
|
||||
const decryptedId = decryptWithFallback(payload.id, ENCRYPTION_KEY);
|
||||
const decryptedEmail = decryptWithFallback(payload.email, ENCRYPTION_KEY);
|
||||
|
||||
return {
|
||||
id: decryptedId,
|
||||
@@ -46,127 +67,230 @@ export const verifyEmailChangeToken = async (token: string): Promise<{ id: strin
|
||||
};
|
||||
|
||||
export const createEmailChangeToken = (userId: string, email: string): string => {
|
||||
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
|
||||
|
||||
const payload = {
|
||||
id: encryptedUserId,
|
||||
email: encryptedEmail,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, env.NEXTAUTH_SECRET as string, {
|
||||
return jwt.sign(payload, NEXTAUTH_SECRET, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
};
|
||||
|
||||
export const createEmailToken = (email: string): string => {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET);
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail }, NEXTAUTH_SECRET);
|
||||
};
|
||||
|
||||
export const getEmailFromEmailToken = (token: string): string => {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as JwtPayload;
|
||||
try {
|
||||
// Try to decrypt first (for newer tokens)
|
||||
const decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
|
||||
return decryptedEmail;
|
||||
} catch {
|
||||
// If decryption fails, return the original email (for older tokens)
|
||||
return payload.email;
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
email: string;
|
||||
};
|
||||
return decryptWithFallback(payload.email, ENCRYPTION_KEY);
|
||||
};
|
||||
|
||||
export const createInviteToken = (inviteId: string, email: string, options = {}): string => {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
const encryptedInviteId = symmetricEncrypt(inviteId, env.ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, env.NEXTAUTH_SECRET, options);
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedInviteId = symmetricEncrypt(inviteId, ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
|
||||
return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, NEXTAUTH_SECRET, options);
|
||||
};
|
||||
|
||||
export const verifyTokenForLinkSurvey = (token: string, surveyId: string): string | null => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { email } = jwt.verify(token, env.NEXTAUTH_SECRET + surveyId) as JwtPayload;
|
||||
let payload: JwtPayload & { email: string; surveyId?: string };
|
||||
|
||||
// Try primary method first (consistent secret)
|
||||
try {
|
||||
// Try to decrypt first (for newer tokens)
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
email: string;
|
||||
surveyId: string;
|
||||
};
|
||||
} catch (primaryError) {
|
||||
logger.error(primaryError, "Token verification failed with primary method");
|
||||
|
||||
// Fallback to legacy method (surveyId-based secret)
|
||||
try {
|
||||
payload = jwt.verify(token, NEXTAUTH_SECRET + surveyId, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
email: string;
|
||||
};
|
||||
} catch (legacyError) {
|
||||
logger.error(legacyError, "Token verification failed with legacy method");
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
const decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
|
||||
return decryptedEmail;
|
||||
} catch {
|
||||
// If decryption fails, return the original email (for older tokens)
|
||||
return email;
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
// Verify the surveyId matches if present in payload (new format)
|
||||
if (payload.surveyId && payload.surveyId !== surveyId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { email } = payload;
|
||||
if (!email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decrypt email with fallback to plain text
|
||||
if (!ENCRYPTION_KEY) {
|
||||
return email; // Return as-is if encryption key not set
|
||||
}
|
||||
|
||||
return decryptWithFallback(email, ENCRYPTION_KEY);
|
||||
} catch (error) {
|
||||
logger.error(error, "Survey link token verification failed");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyToken = async (token: string): Promise<JwtPayload> => {
|
||||
// First decode to get the ID
|
||||
const decoded = jwt.decode(token);
|
||||
const payload: JwtPayload = decoded as JwtPayload;
|
||||
// Helper function to get user email for legacy verification
|
||||
const getUserEmailForLegacyVerification = async (
|
||||
token: string,
|
||||
userId?: string
|
||||
): Promise<{ userId: string; userEmail: string }> => {
|
||||
if (!userId) {
|
||||
const decoded = jwt.decode(token);
|
||||
|
||||
if (!payload) {
|
||||
throw new Error("Token is invalid");
|
||||
// Validate decoded token structure before using it
|
||||
if (
|
||||
!decoded ||
|
||||
typeof decoded !== "object" ||
|
||||
!decoded.id ||
|
||||
typeof decoded.id !== "string" ||
|
||||
decoded.id.trim() === ""
|
||||
) {
|
||||
logger.error("Invalid token: missing or invalid user ID");
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
|
||||
userId = decoded.id;
|
||||
}
|
||||
|
||||
const { id } = payload;
|
||||
if (!id) {
|
||||
throw new Error("Token missing required field: id");
|
||||
const decryptedId = decryptWithFallback(userId, ENCRYPTION_KEY);
|
||||
|
||||
// Validate decrypted ID before database query
|
||||
if (!decryptedId || typeof decryptedId !== "string" || decryptedId.trim() === "") {
|
||||
logger.error("Invalid token: missing or invalid user ID");
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
|
||||
// Try to decrypt the ID (for newer tokens), if it fails use the ID as-is (for older tokens)
|
||||
let decryptedId: string;
|
||||
try {
|
||||
decryptedId = symmetricDecrypt(id, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
decryptedId = id;
|
||||
}
|
||||
|
||||
// If no email provided, look up the user
|
||||
const foundUser = await prisma.user.findUnique({
|
||||
where: { id: decryptedId },
|
||||
});
|
||||
|
||||
if (!foundUser) {
|
||||
throw new Error("User not found");
|
||||
const errorMessage = "User not found";
|
||||
logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const userEmail = foundUser.email;
|
||||
return { userId: decryptedId, userEmail: foundUser.email };
|
||||
};
|
||||
|
||||
return { id: decryptedId, email: userEmail };
|
||||
export const verifyToken = async (token: string): Promise<JwtPayload> => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
let payload: JwtPayload & { id: string };
|
||||
let userData: { userId: string; userEmail: string } | null = null;
|
||||
|
||||
// Try new method first, with smart fallback to legacy
|
||||
try {
|
||||
payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
id: string;
|
||||
};
|
||||
} catch (newMethodError) {
|
||||
logger.error(newMethodError, "Token verification failed with new method");
|
||||
|
||||
// Get user email for legacy verification
|
||||
userData = await getUserEmailForLegacyVerification(token);
|
||||
|
||||
// Try legacy verification with email-based secret
|
||||
try {
|
||||
payload = jwt.verify(token, NEXTAUTH_SECRET + userData.userEmail, {
|
||||
algorithms: ["HS256"],
|
||||
}) as JwtPayload & {
|
||||
id: string;
|
||||
};
|
||||
} catch (legacyMethodError) {
|
||||
logger.error(legacyMethodError, "Token verification failed with legacy method");
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload?.id) {
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
|
||||
// Get user email if we don't have it yet
|
||||
userData ??= await getUserEmailForLegacyVerification(token, payload.id);
|
||||
|
||||
return { id: userData.userId, email: userData.userEmail };
|
||||
};
|
||||
|
||||
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.decode(token);
|
||||
const payload: JwtPayload = decoded as JwtPayload;
|
||||
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
inviteId: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
const { inviteId, email } = payload;
|
||||
const { inviteId: encryptedInviteId, email: encryptedEmail } = payload;
|
||||
|
||||
let decryptedInviteId: string;
|
||||
let decryptedEmail: string;
|
||||
|
||||
try {
|
||||
// Try to decrypt first (for newer tokens)
|
||||
decryptedInviteId = symmetricDecrypt(inviteId, env.ENCRYPTION_KEY);
|
||||
decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
// If decryption fails, use original values (for older tokens)
|
||||
decryptedInviteId = inviteId;
|
||||
decryptedEmail = email;
|
||||
if (!encryptedInviteId || !encryptedEmail) {
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
|
||||
// Decrypt both fields with fallback to original values
|
||||
const decryptedInviteId = decryptWithFallback(encryptedInviteId, ENCRYPTION_KEY);
|
||||
const decryptedEmail = decryptWithFallback(encryptedEmail, ENCRYPTION_KEY);
|
||||
|
||||
return {
|
||||
inviteId: decryptedInviteId,
|
||||
email: decryptedEmail,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { Provider } from "next-auth/providers/index";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||
import { createToken } from "@/lib/jwt";
|
||||
// Import mocked rate limiting functions
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { randomBytes } from "crypto";
|
||||
import { Provider } from "next-auth/providers/index";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { authOptions } from "./authOptions";
|
||||
import { mockUser } from "./mock-data";
|
||||
import { hashPassword } from "./utils";
|
||||
@@ -31,7 +31,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SESSION_MAX_AGE: 86400,
|
||||
NEXTAUTH_SECRET: "test-secret",
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
ENCRYPTION_KEY: "test-encryption-key-32-chars-long",
|
||||
ENCRYPTION_KEY: "12345678901234567890123456789012", // 32 bytes for AES-256
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: false,
|
||||
AUDIT_LOG_GET_USER_IP: false,
|
||||
@@ -261,7 +261,7 @@ describe("authOptions", () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
|
||||
|
||||
const credentials = { token: createToken(mockUser.id, mockUser.email) };
|
||||
const credentials = { token: createToken(mockUser.id) };
|
||||
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"Email already verified"
|
||||
@@ -280,7 +280,7 @@ describe("authOptions", () => {
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
|
||||
const result = await tokenProvider.options.authorize(credentials, {});
|
||||
expect(result.email).toBe(mockUser.email);
|
||||
@@ -303,7 +303,7 @@ describe("authOptions", () => {
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
|
||||
await tokenProvider.options.authorize(credentials, {});
|
||||
|
||||
@@ -315,7 +315,7 @@ describe("authOptions", () => {
|
||||
new Error("Maximum number of requests reached. Please try again later.")
|
||||
);
|
||||
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
@@ -339,7 +339,7 @@ describe("authOptions", () => {
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
|
||||
await tokenProvider.options.authorize(credentials, {});
|
||||
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
import { render } from "@react-email/render";
|
||||
import { createTransport } from "nodemailer";
|
||||
import type SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { TLinkSurveyEmailData } from "@formbricks/types/email";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import type { TResponse } from "@formbricks/types/responses";
|
||||
import type { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserEmail, TUserLocale } from "@formbricks/types/user";
|
||||
import {
|
||||
DEBUG,
|
||||
MAIL_FROM,
|
||||
@@ -17,15 +26,6 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import NewEmailVerification from "@/modules/email/emails/auth/new-email-verification";
|
||||
import { EmailCustomizationPreviewEmail } from "@/modules/email/emails/general/email-customization-preview-email";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { render } from "@react-email/render";
|
||||
import { createTransport } from "nodemailer";
|
||||
import type SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { TLinkSurveyEmailData } from "@formbricks/types/email";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import type { TResponse } from "@formbricks/types/responses";
|
||||
import type { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserEmail, TUserLocale } from "@formbricks/types/user";
|
||||
import { ForgotPasswordEmail } from "./emails/auth/forgot-password-email";
|
||||
import { PasswordResetNotifyEmail } from "./emails/auth/password-reset-notify-email";
|
||||
import { VerificationEmail } from "./emails/auth/verification-email";
|
||||
@@ -111,7 +111,7 @@ export const sendVerificationEmail = async ({
|
||||
}): Promise<boolean> => {
|
||||
try {
|
||||
const t = await getTranslate();
|
||||
const token = createToken(id, email, {
|
||||
const token = createToken(id, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
const verifyLink = `${WEBAPP_URL}/auth/verify?token=${encodeURIComponent(token)}`;
|
||||
@@ -136,7 +136,7 @@ export const sendForgotPasswordEmail = async (user: {
|
||||
locale: TUserLocale;
|
||||
}): Promise<boolean> => {
|
||||
const t = await getTranslate();
|
||||
const token = createToken(user.id, user.email, {
|
||||
const token = createToken(user.id, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
const verifyLink = `${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;
|
||||
|
||||
@@ -241,4 +241,40 @@ describe("ConditionalLogic", () => {
|
||||
|
||||
expect(screen.getAllByTestId("logic-editor").length).toBe(2);
|
||||
});
|
||||
|
||||
test("should clear logicFallback when logic array is empty and logicFallback exists (useEffect)", () => {
|
||||
const mockUpdateQuestion = vi.fn();
|
||||
const mockQuestion: TSurveyQuestion = {
|
||||
id: "testQuestionId",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Test Question" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
logic: [], // Empty logic array
|
||||
logicFallback: "someTarget", // Has logicFallback but no logic
|
||||
};
|
||||
const mockSurvey = {
|
||||
id: "testSurveyId",
|
||||
name: "Test Survey",
|
||||
type: "link",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "testEnvId",
|
||||
status: "inProgress",
|
||||
questions: [mockQuestion],
|
||||
endings: [],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
render(
|
||||
<ConditionalLogic
|
||||
localSurvey={mockSurvey}
|
||||
question={mockQuestion}
|
||||
questionIdx={0}
|
||||
updateQuestion={mockUpdateQuestion}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { logicFallback: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
SplitIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface ConditionalLogicProps {
|
||||
@@ -117,6 +117,12 @@ export function ConditionalLogic({
|
||||
};
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
useEffect(() => {
|
||||
if (question.logic?.length === 0 && question.logicFallback) {
|
||||
updateQuestion(questionIdx, { logicFallback: undefined });
|
||||
}
|
||||
}, [question.logic, questionIdx, question.logicFallback, updateQuestion]);
|
||||
|
||||
return (
|
||||
<div className="mt-4" ref={parent}>
|
||||
<Label className="flex gap-2">
|
||||
|
||||
@@ -59,8 +59,8 @@ export const BadgeContent: React.FC<BadgeContentProps> = ({
|
||||
};
|
||||
|
||||
const content = (
|
||||
<button
|
||||
type="button"
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/prefer-tag-over-role, jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
role={isCopyEnabled ? "button" : undefined}
|
||||
className={getButtonClasses()}
|
||||
onClick={handleCopy}
|
||||
@@ -69,7 +69,7 @@ export const BadgeContent: React.FC<BadgeContentProps> = ({
|
||||
onMouseLeave={isCopyEnabled ? () => setIsHovered(false) : undefined}>
|
||||
<span>{id}</span>
|
||||
{renderIcon()}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const getTooltipContent = () => {
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("IdBadge", () => {
|
||||
test("removes interactive elements when copy is disabled", () => {
|
||||
const { container } = render(<IdBadge id="1734" copyDisabled={true} />);
|
||||
|
||||
const badge = container.querySelector("button");
|
||||
const badge = container.querySelector("div");
|
||||
|
||||
// Should not have cursor-pointer class
|
||||
expect(badge).not.toHaveClass("cursor-pointer");
|
||||
|
||||
@@ -424,12 +424,20 @@ EOT
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
fi
|
||||
|
||||
# Step 3: Build service snippets and inject them BEFORE the volumes section (robust, no sed -i multiline)
|
||||
# Step 3: Build service snippets and inject them BEFORE the volumes section (non-destructive: skip if service exists)
|
||||
services_snippet_file="services_snippet.yml"
|
||||
: > "$services_snippet_file"
|
||||
|
||||
insert_traefik="y"
|
||||
if grep -q "^ traefik:" docker-compose.yml; then insert_traefik="n"; fi
|
||||
|
||||
if [[ $minio_storage == "y" ]]; then
|
||||
cat > "$services_snippet_file" << EOF
|
||||
insert_minio="y"; insert_minio_init="y"
|
||||
if grep -q "^ minio:" docker-compose.yml; then insert_minio="n"; fi
|
||||
if grep -q "^ minio-init:" docker-compose.yml; then insert_minio_init="n"; fi
|
||||
|
||||
if [[ $insert_minio == "y" ]]; then
|
||||
cat >> "$services_snippet_file" << EOF
|
||||
|
||||
minio:
|
||||
restart: always
|
||||
@@ -458,6 +466,11 @@ EOT
|
||||
- "traefik.http.middlewares.minio-cors.headers.addvaryheader=true"
|
||||
- "traefik.http.middlewares.minio-ratelimit.ratelimit.average=100"
|
||||
- "traefik.http.middlewares.minio-ratelimit.ratelimit.burst=200"
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [[ $insert_minio_init == "y" ]]; then
|
||||
cat >> "$services_snippet_file" << EOF
|
||||
minio-init:
|
||||
image: minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868
|
||||
depends_on:
|
||||
@@ -471,7 +484,11 @@ EOT
|
||||
entrypoint: ["/bin/sh", "/tmp/minio-init.sh"]
|
||||
volumes:
|
||||
- ./minio-init.sh:/tmp/minio-init.sh:ro
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [[ $insert_traefik == "y" ]]; then
|
||||
cat >> "$services_snippet_file" << EOF
|
||||
traefik:
|
||||
image: "traefik:v2.7"
|
||||
restart: always
|
||||
@@ -488,6 +505,7 @@ EOT
|
||||
- ./acme.json:/acme.json
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Downgrade MinIO router to plain HTTP when HTTPS is not configured
|
||||
if [[ $https_setup != "y" ]]; then
|
||||
@@ -497,7 +515,8 @@ EOF
|
||||
sed -i "s|accesscontrolalloworiginlist=https://$domain_name|accesscontrolalloworiginlist=http://$domain_name|" "$services_snippet_file"
|
||||
fi
|
||||
else
|
||||
cat > "$services_snippet_file" << EOF
|
||||
if [[ $insert_traefik == "y" ]]; then
|
||||
cat > "$services_snippet_file" << EOF
|
||||
|
||||
traefik:
|
||||
image: "traefik:v2.7"
|
||||
@@ -514,6 +533,9 @@ EOF
|
||||
- ./acme.json:/acme.json
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
EOF
|
||||
else
|
||||
: > "$services_snippet_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
awk '
|
||||
@@ -529,24 +551,51 @@ EOF
|
||||
|
||||
rm -f "$services_snippet_file"
|
||||
|
||||
# Deterministically rewrite the volumes section to include required volumes
|
||||
awk -v add_minio="$minio_storage" '
|
||||
BEGIN { in_vol=0 }
|
||||
/^volumes:/ {
|
||||
print "volumes:";
|
||||
print " postgres:";
|
||||
print " driver: local";
|
||||
print " uploads:";
|
||||
print " driver: local";
|
||||
if (add_minio == "y") {
|
||||
print " minio-data:";
|
||||
print " driver: local";
|
||||
}
|
||||
in_vol=1; skip=1; next
|
||||
}
|
||||
# Skip original volumes block lines until EOF (we already printed ours)
|
||||
{ if (!skip) print }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
# Ensure required volumes exist without removing user-defined volumes
|
||||
if grep -q '^volumes:' docker-compose.yml; then
|
||||
# Ensure postgres
|
||||
if ! awk '/^volumes:/{invol=1; next} invol && (/^[^[:space:]]/ || NF==0){invol=0} invol{ if($1=="postgres:") found=1 } END{ exit(found?0:1) }' docker-compose.yml; then
|
||||
awk '
|
||||
/^volumes:/ { print; invol=1; next }
|
||||
invol && /^[^[:space:]]/ { if(!added){ print " postgres:"; print " driver: local"; added=1 } ; invol=0 }
|
||||
{ print }
|
||||
END { if (invol && !added) { print " postgres:"; print " driver: local" } }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
fi
|
||||
# Ensure redis
|
||||
if ! awk '/^volumes:/{invol=1; next} invol && (/^[^[:space:]]/ || NF==0){invol=0} invol{ if($1=="redis:") found=1 } END{ exit(found?0:1) }' docker-compose.yml; then
|
||||
awk '
|
||||
/^volumes:/ { print; invol=1; next }
|
||||
invol && /^[^[:space:]]/ { if(!added){ print " redis:"; print " driver: local"; added=1 } ; invol=0 }
|
||||
{ print }
|
||||
END { if (invol && !added) { print " redis:"; print " driver: local" } }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
fi
|
||||
# Ensure minio-data if needed
|
||||
if [[ $minio_storage == "y" ]]; then
|
||||
if ! awk '/^volumes:/{invol=1; next} invol && (/^[^[:space:]]/ || NF==0){invol=0} invol{ if($1=="minio-data:") found=1 } END{ exit(found?0:1) }' docker-compose.yml; then
|
||||
awk '
|
||||
/^volumes:/ { print; invol=1; next }
|
||||
invol && /^[^[:space:]]/ { if(!added){ print " minio-data:"; print " driver: local"; added=1 } ; invol=0 }
|
||||
{ print }
|
||||
END { if (invol && !added) { print " minio-data:"; print " driver: local" } }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
fi
|
||||
fi
|
||||
else
|
||||
{
|
||||
echo ""
|
||||
echo "volumes:"
|
||||
echo " postgres:"
|
||||
echo " driver: local"
|
||||
echo " redis:"
|
||||
echo " driver: local"
|
||||
if [[ $minio_storage == "y" ]]; then
|
||||
echo " minio-data:"
|
||||
echo " driver: local"
|
||||
fi
|
||||
} >> docker-compose.yml
|
||||
fi
|
||||
|
||||
# Create minio-init script outside heredoc to avoid variable expansion issues
|
||||
if [[ $minio_storage == "y" ]]; then
|
||||
@@ -618,105 +667,6 @@ MINIO_SCRIPT_EOF
|
||||
|
||||
docker compose up -d
|
||||
|
||||
|
||||
if [[ $minio_storage == "y" ]]; then
|
||||
echo "ℹ️ Waiting for MinIO to be ready..."
|
||||
attempts=0
|
||||
max_attempts=30
|
||||
until docker run --rm --network $(basename "$PWD")_default --entrypoint /bin/sh \
|
||||
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc \
|
||||
"mc alias set minio http://minio:9000 '$minio_root_user' '$minio_root_password' >/dev/null 2>&1 && mc admin info minio >/dev/null 2>&1"; do
|
||||
attempts=$((attempts+1))
|
||||
if [ $attempts -ge $max_attempts ]; then
|
||||
echo "❌ MinIO did not become ready in time. Proceeding, but subsequent steps may fail."
|
||||
break
|
||||
fi
|
||||
echo "...attempt $attempts/$max_attempts"
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "ℹ️ Ensuring bucket exists..."
|
||||
docker run --rm --network $(basename "$PWD")_default \
|
||||
-e MINIO_ROOT_USER="$minio_root_user" \
|
||||
-e MINIO_ROOT_PASSWORD="$minio_root_password" \
|
||||
-e MINIO_BUCKET_NAME="$minio_bucket_name" \
|
||||
--entrypoint /bin/sh \
|
||||
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc '
|
||||
mc alias set minio http://minio:9000 "$minio_root_user" "$minio_root_password" >/dev/null 2>&1;
|
||||
mc mb minio/"$minio_bucket_name" --ignore-existing
|
||||
'
|
||||
|
||||
echo "ℹ️ Ensuring service user and policy exist (idempotent)..."
|
||||
docker run --rm --network $(basename "$PWD")_default \
|
||||
-e MINIO_ROOT_USER="$minio_root_user" \
|
||||
-e MINIO_ROOT_PASSWORD="$minio_root_password" \
|
||||
-e MINIO_SERVICE_USER="$minio_service_user" \
|
||||
-e MINIO_SERVICE_PASSWORD="$minio_service_password" \
|
||||
-e MINIO_BUCKET_NAME="$minio_bucket_name" \
|
||||
--entrypoint /bin/sh \
|
||||
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc '
|
||||
mc alias set minio http://minio:9000 "$minio_root_user" "$minio_root_password" >/dev/null 2>&1;
|
||||
if ! mc admin policy info minio formbricks-policy >/dev/null 2>&1; then
|
||||
cat > /tmp/formbricks-policy.json << EOF
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{ "Effect": "Allow", "Action": ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"], "Resource": ["arn:aws:s3:::$minio_bucket_name/*"] },
|
||||
{ "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": ["arn:aws:s3:::$minio_bucket_name"] }
|
||||
]
|
||||
}
|
||||
EOF
|
||||
mc admin policy create minio formbricks-policy /tmp/formbricks-policy.json >/dev/null 2>&1 || true
|
||||
fi;
|
||||
if ! mc admin user info minio "$minio_service_user" >/dev/null 2>&1; then
|
||||
mc admin user add minio "$minio_service_user" "$minio_service_password" >/dev/null 2>&1 || true
|
||||
fi;
|
||||
mc admin policy attach minio formbricks-policy --user "$minio_service_user" >/dev/null 2>&1 || true
|
||||
'
|
||||
fi
|
||||
|
||||
if [[ $minio_storage == "y" ]]; then
|
||||
echo "⏳ Finalizing MinIO setup..."
|
||||
attempts=0; max_attempts=60
|
||||
while cid=$(docker compose ps -q minio-init 2>/dev/null); do
|
||||
status=$(docker inspect -f '{{.State.Status}}' "$cid" 2>/dev/null || echo "")
|
||||
if [ "$status" = "exited" ] || [ -z "$status" ]; then
|
||||
break
|
||||
fi
|
||||
attempts=$((attempts+1))
|
||||
if [ $attempts -ge $max_attempts ]; then
|
||||
echo "⚠️ minio-init still running after wait; proceeding with cleanup anyway."
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "🧹 Cleaning up minio-init service and references..."
|
||||
|
||||
awk '
|
||||
BEGIN{skip=0}
|
||||
/^services:[[:space:]]*$/ { print; next }
|
||||
/^ minio-init:/ { skip=1; next }
|
||||
/^ [A-Za-z0-9_-]+:/ { if (skip) skip=0 }
|
||||
{ if (!skip) print }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
|
||||
# Remove list-style "- minio-init" lines under depends_on (if any)
|
||||
sed -E -i '/^[[:space:]]*-[[:space:]]*minio-init[[:space:]]*$/d' docker-compose.yml
|
||||
|
||||
# Remove the minio-init mapping and its condition line
|
||||
sed -i '/^[[:space:]]*minio-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml
|
||||
|
||||
# Remove any stopped minio-init container and restart without orphans
|
||||
docker compose rm -f -s minio-init >/dev/null 2>&1 || true
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
# Clean up the temporary minio-init script
|
||||
rm -f minio-init.sh
|
||||
|
||||
echo "✅ MinIO one-time init cleaned up."
|
||||
fi
|
||||
|
||||
echo "🔗 To edit more variables and deeper config, go to the formbricks/docker-compose.yml, edit the file, and restart the container!"
|
||||
|
||||
echo "🚨 Make sure you have set up the DNS records as well as inbound rules for the domain name and IP address of this instance."
|
||||
@@ -780,6 +730,40 @@ get_logs() {
|
||||
sudo docker compose logs
|
||||
}
|
||||
|
||||
cleanup_minio_init() {
|
||||
echo "🧹 Cleaning up MinIO init service and references..."
|
||||
cd formbricks
|
||||
|
||||
# Remove minio-init service block from docker-compose.yml
|
||||
awk '
|
||||
BEGIN{skip=0}
|
||||
/^services:[[:space:]]*$/ { print; next }
|
||||
/^ minio-init:/ { skip=1; next }
|
||||
/^ [A-Za-z0-9_-]+:/ { if (skip) skip=0 }
|
||||
{ if (!skip) print }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
|
||||
# Remove list-style "- minio-init" lines under depends_on (if any)
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -E -i '/^[[:space:]]*-[[:space:]]*minio-init[[:space:]]*$/d' docker-compose.yml
|
||||
else
|
||||
sed -E -i '' '/^[[:space:]]*-[[:space:]]*minio-init[[:space:]]*$/d' docker-compose.yml
|
||||
fi
|
||||
|
||||
# Remove the minio-init mapping and its condition line (mapping style depends_on)
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i '/^[[:space:]]*minio-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml
|
||||
else
|
||||
sed -i '' '/^[[:space:]]*minio-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml
|
||||
fi
|
||||
|
||||
# Remove any stopped minio-init container and restart without orphans
|
||||
docker compose rm -f -s minio-init >/dev/null 2>&1 || true
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
echo "✅ MinIO init cleanup complete."
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
install)
|
||||
install_formbricks
|
||||
@@ -796,6 +780,9 @@ restart)
|
||||
logs)
|
||||
get_logs
|
||||
;;
|
||||
cleanup-minio-init)
|
||||
cleanup_minio_init
|
||||
;;
|
||||
uninstall)
|
||||
uninstall_formbricks
|
||||
;;
|
||||
|
||||
@@ -45,7 +45,7 @@ external_s3_guard() {
|
||||
if ! has_service minio; then
|
||||
print_warning "Detected existing S3 credentials in docker-compose.yml and no bundled MinIO service."
|
||||
print_error "This migration is intended only for setups using local uploads."
|
||||
print_info "No changes were made. If you already use external S3 (incl. AWS), you don't need this migration."
|
||||
print_info "No files were migrated. If you already use external S3 (incl. AWS), you don't need this migration."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
@@ -287,7 +287,8 @@ has_service() {
|
||||
}
|
||||
|
||||
add_or_replace_env_var() {
|
||||
local key="$1"; local value="$2"
|
||||
local key="$1"; local value="$2"; local section="${3:-STORAGE}"
|
||||
|
||||
if grep -q "^[[:space:]]*$key:" docker-compose.yml; then
|
||||
# Replace existing uncommented key
|
||||
if sed --version >/dev/null 2>&1; then sed -i "s|^\([[:space:]]*$key:\).*|\1 \"$value\"|" docker-compose.yml; else sed -i '' "s|^\([[:space:]]*$key:\).*|\1 \"$value\"|" docker-compose.yml; fi
|
||||
@@ -295,29 +296,77 @@ add_or_replace_env_var() {
|
||||
# Uncomment placeholder and set
|
||||
if sed --version >/dev/null 2>&1; then sed -i "s|^[[:space:]]*#[[:space:]]*$key:.*| $key: \"$value\"|" docker-compose.yml; else sed -i '' "s|^[[:space:]]*#[[:space:]]*$key:.*| $key: \"$value\"|" docker-compose.yml; fi
|
||||
else
|
||||
# Append into STORAGE section before OAUTH header if present
|
||||
awk -v insert_key="$key" -v insert_val="$value" '
|
||||
BEGIN{printed=0}
|
||||
/################################################### OPTIONAL \(STORAGE\) ###################################################/ {print; in_storage=1; next}
|
||||
in_storage && /############################################# OPTIONAL \(OAUTH CONFIGURATION\) #############################################/ && !printed { print " " insert_key ": \"" insert_val "\""; printed=1; print; in_storage=0; next }
|
||||
{ print }
|
||||
END { if(in_storage && !printed) print " " insert_key ": \"" insert_val "\"" }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
# Add to specified section with fallback
|
||||
local section_found=false
|
||||
|
||||
if [[ "$section" == "REQUIRED" ]] && grep -q -E "^[[:space:]]*#+[[:space:]]*REQUIRED[[:space:]]*#+[[:space:]]*$" docker-compose.yml; then
|
||||
# Add to REQUIRED section
|
||||
awk -v insert_key="$key" -v insert_val="$value" '
|
||||
/^[[:space:]]*#+[[:space:]]*REQUIRED[[:space:]]*#+[[:space:]]*$/ {print; in_required=1; next}
|
||||
in_required && /^[[:space:]]*#+.*OPTIONAL/ && !printed {
|
||||
print ""; print " # " insert_key " (required for Formbricks 4.0)";
|
||||
print " " insert_key ": \"" insert_val "\""; printed=1
|
||||
}
|
||||
{ print }
|
||||
END { if(in_required && !printed) { print ""; print " # " insert_key " (required for Formbricks 4.0)"; print " " insert_key ": \"" insert_val "\"" } }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
section_found=true
|
||||
elif [[ "$section" == "STORAGE" ]] && grep -q -E "^[[:space:]]*#+[[:space:]]*OPTIONAL[[:space:]]*\\([[:space:]]*STORAGE[[:space:]]*\\)[[:space:]]*#+[[:space:]]*$" docker-compose.yml; then
|
||||
# Add to STORAGE section (original behavior)
|
||||
awk -v insert_key="$key" -v insert_val="$value" '
|
||||
BEGIN{printed=0}
|
||||
/^[[:space:]]*#+[[:space:]]*OPTIONAL[[:space:]]*\([[:space:]]*STORAGE[[:space:]]*\)[[:space:]]*#+[[:space:]]*$/ {print; in_storage=1; next}
|
||||
in_storage && /^[[:space:]]*#+.*OPTIONAL.*OAUTH/ && !printed {
|
||||
print " " insert_key ": \"" insert_val "\""; printed=1; print; in_storage=0; next
|
||||
}
|
||||
{ print }
|
||||
END { if(in_storage && !printed) print " " insert_key ": \"" insert_val "\"" }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
section_found=true
|
||||
fi
|
||||
|
||||
# Fallback: add at the end of environment block if section not found
|
||||
if [[ "$section_found" == false ]]; then
|
||||
awk -v insert_key="$key" -v insert_val="$value" '
|
||||
/^ environment:/ {print; in_env=1; next}
|
||||
in_env && /^[^[:space:]]/ {
|
||||
if (!printed) { print " " insert_key ": \"" insert_val "\""; printed=1 }
|
||||
in_env=0
|
||||
}
|
||||
{ print }
|
||||
END { if(in_env && !printed) print " " insert_key ": \"" insert_val "\"" }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to check if we're in the correct directory
|
||||
check_formbricks_directory() {
|
||||
if [[ ! -f "docker-compose.yml" ]]; then
|
||||
print_error "docker-compose.yml not found in current directory!"
|
||||
print_info "Please run this script from your Formbricks installation directory (usually ./formbricks/)"
|
||||
exit 1
|
||||
# Case 1: docker-compose.yml in current directory
|
||||
if [[ -f "docker-compose.yml" ]]; then
|
||||
if grep -q "formbricks" docker-compose.yml; then
|
||||
return 0
|
||||
else
|
||||
print_error "This doesn't appear to be a Formbricks docker-compose.yml file!"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! grep -q "formbricks" docker-compose.yml; then
|
||||
print_error "This doesn't appear to be a Formbricks docker-compose.yml file!"
|
||||
exit 1
|
||||
|
||||
# Case 2: one-click setup parent directory containing ./formbricks/docker-compose.yml
|
||||
if [[ -f "formbricks/docker-compose.yml" ]]; then
|
||||
cd formbricks
|
||||
print_status "Detected one-click setup layout. Switched to ./formbricks directory."
|
||||
if ! grep -q "formbricks" docker-compose.yml; then
|
||||
print_error "This doesn't appear to be a Formbricks docker-compose.yml file!"
|
||||
exit 1
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Neither current directory nor ./formbricks contains a compose file
|
||||
print_error "docker-compose.yml not found in current directory or in ./formbricks/"
|
||||
print_info "Run this script from the parent directory created by the one-click setup (containing ./formbricks/), or from the directory containing docker-compose.yml."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Function to backup existing docker-compose.yml
|
||||
@@ -635,35 +684,202 @@ ${tls_block}
|
||||
print_status "Added MinIO service to docker-compose.yml"
|
||||
}
|
||||
|
||||
# Function to add minio-init dependency to formbricks service
|
||||
add_minio_dependency() {
|
||||
# Only add if not already present
|
||||
if ! awk '/formbricks:/,/depends_on:/{ if($0 ~ /minio-init/) found=1 } END{ exit(found) }' docker-compose.yml; then
|
||||
if sed --version >/dev/null 2>&1; then sed -i '/formbricks:/,/depends_on:/{/- postgres/a\
|
||||
- minio-init
|
||||
}' docker-compose.yml; else sed -i '' '/formbricks:/,/depends_on:/{/- postgres/a\
|
||||
- minio-init
|
||||
}' docker-compose.yml; fi
|
||||
print_status "Added minio-init dependency to formbricks service"
|
||||
# Generic function to add service dependency
|
||||
add_service_dependency() {
|
||||
local service="$1" # Target service (e.g., "formbricks", "traefik")
|
||||
local dependency="$2" # Dependency to add (e.g., "redis", "minio-init", "minio")
|
||||
local optional="${3:-false}" # Optional parameter - if true, don't exit if service not found
|
||||
|
||||
# Check if service exists
|
||||
if ! grep -q "^ $service:" docker-compose.yml; then
|
||||
if [[ "$optional" == "true" ]]; then
|
||||
print_info "$service service not found - skipping dependency addition."
|
||||
return 0
|
||||
else
|
||||
print_error "$service service not found in docker-compose.yml!"
|
||||
print_info "Please ensure the $service service is properly configured before running this migration."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if dependency is already present in the service depends_on section
|
||||
if awk -v srv="$service" -v dep="$dependency" 'BEGIN{found=0} /^ / && $0 ~ "^ " srv ":" {in_svc=1} in_svc && /^ [a-zA-Z]/ && $0 !~ "^ " srv ":" {in_svc=0} in_svc && $0 ~ "^[[:space:]]*-[[:space:]]*" dep "[[:space:]]*$" {found=1} END{exit(!found)}' docker-compose.yml; then
|
||||
# Dependency already present, skip addition
|
||||
print_info "$dependency dependency already present in $service service."
|
||||
else
|
||||
print_info "minio-init dependency already present."
|
||||
# Write awk script to temporary file to avoid shell escaping issues
|
||||
local awk_script_tmp
|
||||
awk_script_tmp=$(mktemp)
|
||||
cat > "$awk_script_tmp" << 'AWK_EOF'
|
||||
# Store all lines to be able to look ahead
|
||||
{
|
||||
lines[NR] = $0
|
||||
}
|
||||
END {
|
||||
# First pass: check if target service has depends_on
|
||||
for (i = 1; i <= NR; i++) {
|
||||
if (lines[i] ~ "^ " srv ":") {
|
||||
in_target = 1
|
||||
start_line = i
|
||||
} else if (in_target && lines[i] ~ /^ [a-zA-Z]/ && lines[i] !~ "^ " srv ":") {
|
||||
in_target = 0
|
||||
end_line = i - 1
|
||||
break
|
||||
}
|
||||
if (in_target && lines[i] ~ /^[[:space:]]*depends_on:[[:space:]]*$/) {
|
||||
has_depends_on = 1
|
||||
}
|
||||
}
|
||||
if (in_target && !end_line) end_line = NR
|
||||
|
||||
# Second pass: output with modifications
|
||||
in_svc = 0; in_depends = 0; added = 0
|
||||
for (i = 1; i <= NR; i++) {
|
||||
line = lines[i]
|
||||
|
||||
if (line ~ "^ " srv ":") {
|
||||
in_svc = 1
|
||||
print line
|
||||
continue
|
||||
}
|
||||
|
||||
if (in_svc && line ~ /^ [a-zA-Z]/ && line !~ "^ " srv ":") {
|
||||
# Exiting service
|
||||
if (!has_depends_on && !added) {
|
||||
print " depends_on:"
|
||||
print " - " new_dep
|
||||
added = 1
|
||||
}
|
||||
in_svc = 0; in_depends = 0
|
||||
print line
|
||||
continue
|
||||
}
|
||||
|
||||
if (in_svc && line ~ /^[[:space:]]*depends_on:[[:space:]]*$/) {
|
||||
in_depends = 1
|
||||
print line
|
||||
continue
|
||||
}
|
||||
|
||||
if (in_svc && in_depends && line ~ /^[[:space:]]*-[[:space:]]*/) {
|
||||
print line
|
||||
continue
|
||||
}
|
||||
|
||||
if (in_svc && in_depends && line !~ /^[[:space:]]*-[[:space:]]*/) {
|
||||
# End of depends_on list
|
||||
if (!added) {
|
||||
print " - " new_dep
|
||||
added = 1
|
||||
}
|
||||
in_depends = 0
|
||||
print line
|
||||
continue
|
||||
}
|
||||
|
||||
if (in_svc && line ~ /^[[:space:]]+[a-zA-Z_][^:]*:[[:space:]]*/) {
|
||||
# First property in service without depends_on
|
||||
if (!has_depends_on && !added) {
|
||||
print " depends_on:"
|
||||
print " - " new_dep
|
||||
added = 1
|
||||
has_depends_on = 1
|
||||
}
|
||||
print line
|
||||
continue
|
||||
}
|
||||
|
||||
print line
|
||||
}
|
||||
|
||||
# Handle case where service ends at EOF
|
||||
if (in_depends && !added) {
|
||||
print " - " new_dep
|
||||
} else if (in_svc && !has_depends_on && !added) {
|
||||
print " depends_on:"
|
||||
print " - " new_dep
|
||||
}
|
||||
}
|
||||
AWK_EOF
|
||||
|
||||
# Use the awk script with variables
|
||||
awk -v srv="$service" -v new_dep="$dependency" -f "$awk_script_tmp" docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
rm -f "$awk_script_tmp"
|
||||
print_status "Added $dependency dependency to $service service"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to update Traefik configuration to include MinIO dependency
|
||||
update_traefik_config() {
|
||||
# Check if traefik service exists and add minio dependency
|
||||
if grep -q "traefik:" docker-compose.yml; then
|
||||
if ! awk '/traefik:/,/depends_on:/{ if($0 ~ /- minio$/) found=1 } END{ exit(found) }' docker-compose.yml; then
|
||||
if sed --version >/dev/null 2>&1; then sed -i '/traefik:/,/depends_on:/{/- formbricks/a\
|
||||
- minio
|
||||
}' docker-compose.yml; else sed -i '' '/traefik:/,/depends_on:/{/- formbricks/a\
|
||||
- minio
|
||||
}' docker-compose.yml; fi
|
||||
print_status "Updated Traefik configuration to include MinIO dependency"
|
||||
else
|
||||
print_info "Traefik already depends_on minio."
|
||||
fi
|
||||
# Function to add Redis environment variable
|
||||
add_redis_environment_variables() {
|
||||
# Use the enhanced helper function with REQUIRED section
|
||||
add_or_replace_env_var "REDIS_URL" "redis://redis:6379" "REQUIRED"
|
||||
print_status "Redis environment variables ensured in docker-compose.yml"
|
||||
}
|
||||
|
||||
# Function to add Redis service to docker-compose.yml
|
||||
add_redis_service() {
|
||||
# Skip injecting if service already exists
|
||||
if has_service redis; then
|
||||
print_info "Redis service already present. Skipping service injection."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Create Redis service configuration
|
||||
local redis_service_config="
|
||||
redis:
|
||||
restart: always
|
||||
image: valkey/valkey@sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d
|
||||
command: valkey-server --appendonly yes
|
||||
volumes:
|
||||
- redis:/data
|
||||
"
|
||||
|
||||
# Write Redis service to temporary file
|
||||
echo "$redis_service_config" > redis_service.tmp
|
||||
|
||||
# Add Redis service before the volumes section
|
||||
awk '
|
||||
{
|
||||
print
|
||||
if ($0 ~ /^services:$/ && !inserted) {
|
||||
while ((getline line < "redis_service.tmp") > 0) print line
|
||||
close("redis_service.tmp")
|
||||
inserted = 1
|
||||
}
|
||||
}
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
|
||||
# Clean up temporary file
|
||||
rm -f redis_service.tmp
|
||||
|
||||
print_status "Added Redis service to docker-compose.yml"
|
||||
}
|
||||
|
||||
# Function to add redis volume
|
||||
add_redis_volume() {
|
||||
# Ensure redis volume exists once
|
||||
if grep -q '^volumes:' docker-compose.yml; then
|
||||
# volumes block exists; check for redis inside it
|
||||
if awk '/^volumes:/{invol=1; next} invol && NF==0{invol=0} invol{ if($1=="redis:") found=1 } END{ exit(!found) }' docker-compose.yml; then
|
||||
print_info "Redis volume already present."
|
||||
else
|
||||
awk '
|
||||
/^volumes:/ { print; invol=1; next }
|
||||
invol && /^[^[:space:]]/ { if(!added){ print " redis:"; print " driver: local"; added=1 } ; invol=0 }
|
||||
{ print }
|
||||
END { if (invol && !added) { print " redis:"; print " driver: local" } }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
print_status "Redis volume ensured"
|
||||
fi
|
||||
else
|
||||
# no volumes block; append one with redis only (non-destructive to services)
|
||||
{
|
||||
echo ""
|
||||
echo "volumes:"
|
||||
echo " redis:"
|
||||
echo " driver: local"
|
||||
} >> docker-compose.yml
|
||||
print_status "Added volumes section with Redis"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -885,19 +1101,21 @@ migrate_files_to_minio() {
|
||||
|
||||
# Function to restart Docker Compose
|
||||
restart_docker_compose() {
|
||||
echo -n "Restart Docker Compose now to start MinIO and apply changes? [Y/n]: "
|
||||
echo -n "Restart now to apply changes and continue the migration? (recommended) [Y/n]: "
|
||||
local restart_confirm
|
||||
read -r restart_confirm
|
||||
restart_confirm=$(echo "$restart_confirm" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ -z "$restart_confirm" || "$restart_confirm" == "y" ]]; then
|
||||
print_info "Stopping current services..."
|
||||
print_info "We need to briefly restart Formbricks to apply the changes."
|
||||
print_info "Stopping services..."
|
||||
docker compose down
|
||||
print_info "Starting services with MinIO..."
|
||||
docker compose up -d
|
||||
print_status "Docker Compose restarted successfully!"
|
||||
return 0
|
||||
else
|
||||
print_warning "Skipping restart. You can run 'docker compose down && docker compose up -d' later to start MinIO."
|
||||
print_warning "Skipping restart for now."
|
||||
print_info "When you're ready, run: docker compose down && docker compose up -d"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
@@ -924,16 +1142,34 @@ wait_for_service_up() {
|
||||
}
|
||||
|
||||
# Main migration function
|
||||
migrate_to_minio() {
|
||||
echo "🧱 Formbricks MinIO Migration Script for v4.0"
|
||||
echo "=============================================="
|
||||
migrate_to_v4() {
|
||||
echo "🧱 Formbricks v4.0 Migration"
|
||||
echo "============================"
|
||||
echo ""
|
||||
print_info "We'll prepare your Formbricks instance for v4.0 by:"
|
||||
print_info "- Adding Redis (for caching)"
|
||||
print_info "- Adding MinIO (for file storage)"
|
||||
print_info "- Moving your existing files into MinIO"
|
||||
print_info "You'll be asked to restart briefly so changes take effect."
|
||||
echo ""
|
||||
|
||||
# Check if we're in the right directory
|
||||
check_formbricks_directory
|
||||
|
||||
# Backup docker-compose.yml before making any changes
|
||||
backup_docker_compose
|
||||
|
||||
# Add Redis configuration first (prerequisite for Formbricks 4.0)
|
||||
print_status "Setting up Redis..."
|
||||
add_redis_environment_variables
|
||||
add_redis_service
|
||||
add_redis_volume
|
||||
add_service_dependency "formbricks" "redis"
|
||||
echo ""
|
||||
|
||||
# Abort early if external S3 already configured and no bundled MinIO
|
||||
external_s3_guard
|
||||
|
||||
|
||||
# Check if MinIO is already configured unless migrating only
|
||||
if [[ "$MIGRATE_ONLY" != true ]]; then
|
||||
if [[ "$FORCE_RECONFIGURE" == true ]]; then
|
||||
@@ -954,8 +1190,8 @@ migrate_to_minio() {
|
||||
fi
|
||||
|
||||
print_info "Detected configuration:"
|
||||
print_info " Main domain: $main_domain"
|
||||
print_info " HTTPS setup: $https_setup"
|
||||
print_info "Main domain: $main_domain"
|
||||
print_info "HTTPS setup: $https_setup"
|
||||
echo ""
|
||||
|
||||
local files_domain
|
||||
@@ -984,7 +1220,7 @@ migrate_to_minio() {
|
||||
exit 1
|
||||
fi
|
||||
local default_files_domain="files.$main_domain"
|
||||
echo -n "Enter the files subdomain for MinIO (e.g., $default_files_domain): "
|
||||
echo -n "Enter the files subdomain to use for MinIO (e.g., $default_files_domain): "
|
||||
read files_domain
|
||||
if [[ -z "$files_domain" ]]; then
|
||||
files_domain="$default_files_domain"
|
||||
@@ -998,9 +1234,6 @@ migrate_to_minio() {
|
||||
generate_minio_credentials
|
||||
echo ""
|
||||
|
||||
# Backup docker-compose.yml
|
||||
backup_docker_compose
|
||||
|
||||
# If reconfiguring/rotating creds, remove existing service blocks so reinjection is clean
|
||||
if [[ "$REGENERATE_CREDS" == true || "$FORCE_RECONFIGURE" == true ]]; then
|
||||
remove_minio_services || true
|
||||
@@ -1013,10 +1246,10 @@ migrate_to_minio() {
|
||||
add_minio_service "$files_domain" "$main_domain" "$https_setup"
|
||||
|
||||
# Add MinIO dependency to formbricks
|
||||
add_minio_dependency
|
||||
add_service_dependency "formbricks" "minio-init"
|
||||
|
||||
# Update Traefik configuration
|
||||
update_traefik_config
|
||||
# Update Traefik configuration (optional - skip if traefik not present)
|
||||
add_service_dependency "traefik" "minio" "true"
|
||||
|
||||
# Add MinIO volume
|
||||
add_minio_volume
|
||||
@@ -1034,6 +1267,26 @@ migrate_to_minio() {
|
||||
if restart_docker_compose; then
|
||||
restart_success=true
|
||||
proceed_migration=true
|
||||
else
|
||||
# User declined restart; confirm they understand migration cannot proceed without it
|
||||
print_warning "Without restarting now, the migration cannot proceed."
|
||||
echo -n "Restart Docker Compose now to proceed with migration? [Y/n]: "
|
||||
read -r confirm_restart
|
||||
confirm_restart=$(echo "$confirm_restart" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ -z "$confirm_restart" || "$confirm_restart" == "y" ]]; then
|
||||
if restart_docker_compose; then
|
||||
restart_success=true
|
||||
proceed_migration=true
|
||||
else
|
||||
print_warning "Migration cancelled because restart was declined."
|
||||
print_info "You can run 'docker compose down && docker compose up -d' later, then rerun this script to migrate files."
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
print_warning "Migration cancelled at your request. No files were migrated."
|
||||
print_info "You can restart later and rerun this script to migrate files."
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# Try to ensure services are up without full restart
|
||||
@@ -1138,22 +1391,25 @@ migrate_to_minio() {
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 MinIO Migration Complete!"
|
||||
echo "============================="
|
||||
echo "🎉 Formbricks v4.0 Migration Complete!"
|
||||
echo "======================================="
|
||||
echo ""
|
||||
print_status "MinIO Configuration:"
|
||||
print_info " Files Domain: $files_domain"
|
||||
print_info " S3 Access Key: $minio_service_user"
|
||||
print_info " S3 Bucket: $minio_bucket_name"
|
||||
print_status "Infrastructure Configuration:"
|
||||
print_info "Redis://redis:6379"
|
||||
print_info "Files Domain: $files_domain"
|
||||
print_info "S3 Access Key: $minio_service_user"
|
||||
print_info "S3 Bucket: $minio_bucket_name"
|
||||
echo ""
|
||||
|
||||
if [[ "$restart_success" == true ]]; then
|
||||
print_status "Your Formbricks instance is now using MinIO for file storage!"
|
||||
print_info "You can check the status with: docker compose ps"
|
||||
print_info "View logs with: docker compose logs"
|
||||
print_status "Your Formbricks instance is now ready for v4.0!"
|
||||
print_info "- Redis is configured for caching"
|
||||
print_info "- MinIO is configured for file storage"
|
||||
print_info "To check that everything is running, use: docker compose ps"
|
||||
print_info "To view logs (for troubleshooting), use: docker compose logs"
|
||||
else
|
||||
print_warning "Remember to restart your Docker Compose setup:"
|
||||
print_info " docker compose down && docker compose up -d"
|
||||
print_warning "Before migration can happen, please remember to restart your services:"
|
||||
print_info "docker compose down && docker compose up -d"
|
||||
fi
|
||||
|
||||
if [[ "$MIGRATE_ONLY" != true ]]; then
|
||||
@@ -1211,6 +1467,6 @@ cleanup_uploads_from_compose() {
|
||||
fi
|
||||
|
||||
# Check if script is being run directly
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
migrate_to_minio
|
||||
if [[ -n "${BASH_SOURCE:-}" && "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
migrate_to_v4
|
||||
fi
|
||||
@@ -133,11 +133,13 @@ REDIS_URL=redis://your-redis-host:6379
|
||||
Configure S3 storage by adding the following environment variables to your instances:
|
||||
|
||||
```sh env
|
||||
# Required for file uploads in serverless environments
|
||||
# Required
|
||||
S3_BUCKET_NAME=your-bucket-name
|
||||
|
||||
# Optional - if not provided, AWS SDK will use defaults (us-east-1) or auto-detect
|
||||
S3_ACCESS_KEY=your-access-key
|
||||
S3_SECRET_KEY=your-secret-key
|
||||
S3_REGION=your-region
|
||||
S3_BUCKET_NAME=your-bucket-name
|
||||
|
||||
# For S3-compatible storage (e.g., StorJ, MinIO)
|
||||
# Leave empty for Amazon S3
|
||||
|
||||
@@ -310,6 +310,18 @@ To restart Formbricks, simply run the following command:
|
||||
|
||||
The script will automatically restart all the Formbricks related containers and brings the entire stack up with the previous configuration.
|
||||
|
||||
## Cleanup MinIO init (optional)
|
||||
|
||||
During the one-click setup, a temporary `minio-init` service configures MinIO (bucket, policy, service user). It is idempotent and safe to leave in place; it will do nothing on subsequent starts once configuration exists.
|
||||
|
||||
If you prefer to remove the `minio-init` service and its references after a successful setup, run:
|
||||
|
||||
```
|
||||
./formbricks.sh cleanup-minio-init
|
||||
```
|
||||
|
||||
This only removes the init job and its Compose references; it does not delete any data or affect your MinIO configuration.
|
||||
|
||||
## Uninstall
|
||||
|
||||
To uninstall Formbricks, simply run the following command, but keep in mind that this will delete all your data!
|
||||
|
||||
@@ -227,8 +227,8 @@ describe("client.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when region is missing", async () => {
|
||||
// Mock constants with missing region
|
||||
test("should create S3 client when region is missing (uses AWS SDK defaults)", async () => {
|
||||
// Mock constants with missing region - should still work
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_REGION: undefined,
|
||||
@@ -238,9 +238,44 @@ describe("client.ts", () => {
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("s3_credentials_error");
|
||||
expect(mockS3Client).toHaveBeenCalledWith({
|
||||
credentials: {
|
||||
accessKeyId: mockConstants.S3_ACCESS_KEY,
|
||||
secretAccessKey: mockConstants.S3_SECRET_KEY,
|
||||
},
|
||||
endpoint: mockConstants.S3_ENDPOINT_URL,
|
||||
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("should create S3 client with only bucket name (minimal config for IAM roles)", async () => {
|
||||
// Mock constants with only bucket name - minimal required config
|
||||
vi.doMock("./constants", () => ({
|
||||
S3_ACCESS_KEY: undefined,
|
||||
S3_SECRET_KEY: undefined,
|
||||
S3_REGION: undefined,
|
||||
S3_BUCKET_NAME: "test-bucket",
|
||||
S3_ENDPOINT_URL: undefined,
|
||||
S3_FORCE_PATH_STYLE: false,
|
||||
}));
|
||||
|
||||
const { createS3ClientFromEnv } = await import("./client");
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(mockS3Client).toHaveBeenCalledWith({
|
||||
endpoint: undefined,
|
||||
forcePathStyle: false,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -318,10 +353,9 @@ describe("client.ts", () => {
|
||||
});
|
||||
|
||||
test("should return undefined when creating from env fails and no client provided", async () => {
|
||||
// Mock constants with missing required fields (region and bucket)
|
||||
// Mock constants with missing required field (bucket name only)
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_REGION: undefined,
|
||||
S3_BUCKET_NAME: undefined,
|
||||
}));
|
||||
|
||||
@@ -354,7 +388,6 @@ describe("client.ts", () => {
|
||||
test("returns undefined when env is invalid and does not construct client", async () => {
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_REGION: undefined,
|
||||
S3_BUCKET_NAME: undefined,
|
||||
}));
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@ let cachedS3Client: S3Client | undefined;
|
||||
*/
|
||||
export const createS3ClientFromEnv = (): Result<S3Client, StorageError> => {
|
||||
try {
|
||||
// S3_REGION and S3_BUCKET_NAME are always required
|
||||
if (!S3_BUCKET_NAME || !S3_REGION) {
|
||||
logger.error("S3 Client: S3_REGION and S3_BUCKET_NAME are required");
|
||||
// Only S3_BUCKET_NAME is required - S3_REGION is optional and will default to AWS SDK defaults
|
||||
if (!S3_BUCKET_NAME) {
|
||||
logger.error("S3 Client: S3_BUCKET_NAME is required");
|
||||
return err({
|
||||
code: StorageErrorCode.S3CredentialsError,
|
||||
});
|
||||
@@ -29,11 +29,15 @@ export const createS3ClientFromEnv = (): Result<S3Client, StorageError> => {
|
||||
|
||||
// Build S3 client configuration
|
||||
const s3Config: S3ClientConfig = {
|
||||
region: S3_REGION,
|
||||
endpoint: S3_ENDPOINT_URL,
|
||||
forcePathStyle: S3_FORCE_PATH_STYLE,
|
||||
};
|
||||
|
||||
// Only set region if it's provided, otherwise let AWS SDK use its defaults
|
||||
if (S3_REGION) {
|
||||
s3Config.region = S3_REGION;
|
||||
}
|
||||
|
||||
// Only add credentials if both access key and secret key are provided
|
||||
// This allows the AWS SDK to use IAM roles, instance profiles, or other credential providers
|
||||
if (S3_ACCESS_KEY && S3_SECRET_KEY) {
|
||||
|
||||
Reference in New Issue
Block a user