mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-01 03:33:40 -05:00
546 lines
16 KiB
TypeScript
546 lines
16 KiB
TypeScript
import "server-only";
|
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
import fetch from "node-fetch";
|
|
import { cache as reactCache } from "react";
|
|
import { z } from "zod";
|
|
import { createCacheKey } from "@formbricks/cache";
|
|
import { prisma } from "@formbricks/database";
|
|
import { logger } from "@formbricks/logger";
|
|
import { cache } from "@/lib/cache";
|
|
import { E2E_TESTING } from "@/lib/constants";
|
|
import { env } from "@/lib/env";
|
|
import { hashString } from "@/lib/hash-string";
|
|
import { getInstanceId } from "@/lib/instance";
|
|
import {
|
|
TEnterpriseLicenseDetails,
|
|
TEnterpriseLicenseFeatures,
|
|
} from "@/modules/ee/license-check/types/enterprise-license";
|
|
|
|
// Configuration
|
|
const CONFIG = {
|
|
CACHE: {
|
|
FETCH_LICENSE_TTL_MS: 24 * 60 * 60 * 1000, // 24 hours
|
|
PREVIOUS_RESULT_TTL_MS: 4 * 24 * 60 * 60 * 1000, // 4 days
|
|
GRACE_PERIOD_MS: 3 * 24 * 60 * 60 * 1000, // 3 days
|
|
MAX_RETRIES: 3,
|
|
RETRY_DELAY_MS: 1000,
|
|
},
|
|
API: {
|
|
ENDPOINT:
|
|
env.ENVIRONMENT === "staging"
|
|
? "https://staging.ee.formbricks.com/api/licenses/check"
|
|
: "https://ee.formbricks.com/api/licenses/check",
|
|
// ENDPOINT: "https://localhost:8080/api/licenses/check",
|
|
TIMEOUT_MS: 5000,
|
|
},
|
|
} as const;
|
|
|
|
// Types
|
|
type FallbackLevel = "live" | "cached" | "grace" | "default";
|
|
|
|
type TEnterpriseLicenseStatusReturn = "active" | "expired" | "unreachable" | "no-license";
|
|
|
|
type TEnterpriseLicenseResult = {
|
|
active: boolean;
|
|
features: TEnterpriseLicenseFeatures | null;
|
|
lastChecked: Date;
|
|
isPendingDowngrade: boolean;
|
|
fallbackLevel: FallbackLevel;
|
|
status: TEnterpriseLicenseStatusReturn;
|
|
};
|
|
|
|
type TPreviousResult = {
|
|
active: boolean;
|
|
lastChecked: Date;
|
|
features: TEnterpriseLicenseFeatures | null;
|
|
};
|
|
|
|
// Validation schemas
|
|
const LicenseFeaturesSchema = z.object({
|
|
isMultiOrgEnabled: z.boolean(),
|
|
projects: z.number().nullable(),
|
|
twoFactorAuth: z.boolean(),
|
|
sso: z.boolean(),
|
|
whitelabel: z.boolean(),
|
|
removeBranding: z.boolean(),
|
|
contacts: z.boolean(),
|
|
ai: z.boolean(),
|
|
saml: z.boolean(),
|
|
spamProtection: z.boolean(),
|
|
auditLogs: z.boolean(),
|
|
multiLanguageSurveys: z.boolean(),
|
|
accessControl: z.boolean(),
|
|
quotas: z.boolean(),
|
|
});
|
|
|
|
const LicenseDetailsSchema = z.object({
|
|
status: z.enum(["active", "expired"]),
|
|
features: LicenseFeaturesSchema,
|
|
});
|
|
|
|
// Error types
|
|
class LicenseError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly code: string
|
|
) {
|
|
super(message);
|
|
this.name = "LicenseError";
|
|
}
|
|
}
|
|
|
|
class LicenseApiError extends LicenseError {
|
|
constructor(
|
|
message: string,
|
|
public readonly status: number
|
|
) {
|
|
super(message, "API_ERROR");
|
|
this.name = "LicenseApiError";
|
|
}
|
|
}
|
|
|
|
// Cache keys using enterprise-grade hierarchical patterns
|
|
const getCacheIdentifier = () => {
|
|
if (globalThis.window !== undefined) {
|
|
return "browser"; // Browser environment
|
|
}
|
|
if (!env.ENTERPRISE_LICENSE_KEY) {
|
|
return "no-license"; // No license key provided
|
|
}
|
|
return hashString(env.ENTERPRISE_LICENSE_KEY); // Valid license key
|
|
};
|
|
|
|
export const getCacheKeys = () => {
|
|
const identifier = getCacheIdentifier();
|
|
return {
|
|
FETCH_LICENSE_CACHE_KEY: createCacheKey.license.status(identifier),
|
|
PREVIOUS_RESULT_CACHE_KEY: createCacheKey.license.previous_result(identifier),
|
|
};
|
|
};
|
|
|
|
// Default features
|
|
const DEFAULT_FEATURES: TEnterpriseLicenseFeatures = {
|
|
isMultiOrgEnabled: false,
|
|
projects: 3,
|
|
twoFactorAuth: false,
|
|
sso: false,
|
|
whitelabel: false,
|
|
removeBranding: false,
|
|
contacts: false,
|
|
ai: false,
|
|
saml: false,
|
|
spamProtection: false,
|
|
auditLogs: false,
|
|
multiLanguageSurveys: false,
|
|
accessControl: false,
|
|
quotas: false,
|
|
};
|
|
|
|
// Helper functions
|
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
const validateConfig = () => {
|
|
const errors: string[] = [];
|
|
if (CONFIG.CACHE.GRACE_PERIOD_MS >= CONFIG.CACHE.PREVIOUS_RESULT_TTL_MS) {
|
|
errors.push("Grace period must be shorter than previous result TTL");
|
|
}
|
|
if (CONFIG.CACHE.MAX_RETRIES < 0) {
|
|
errors.push("Max retries must be non-negative");
|
|
}
|
|
if (errors.length > 0) {
|
|
throw new LicenseError(errors.join(", "), "CONFIG_ERROR");
|
|
}
|
|
};
|
|
|
|
// Cache functions with async pattern
|
|
let getPreviousResultPromise: Promise<TPreviousResult> | null = null;
|
|
|
|
const getPreviousResult = async (): Promise<TPreviousResult> => {
|
|
if (getPreviousResultPromise) return getPreviousResultPromise;
|
|
|
|
getPreviousResultPromise = (async () => {
|
|
if (globalThis.window !== undefined) {
|
|
return {
|
|
active: false,
|
|
lastChecked: new Date(0),
|
|
features: DEFAULT_FEATURES,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const result = await cache.get<TPreviousResult>(getCacheKeys().PREVIOUS_RESULT_CACHE_KEY);
|
|
if (result.ok && result.data) {
|
|
return {
|
|
...result.data,
|
|
lastChecked: new Date(result.data.lastChecked),
|
|
};
|
|
}
|
|
} catch (error) {
|
|
logger.error({ error }, "Failed to get previous result from cache");
|
|
}
|
|
|
|
return {
|
|
active: false,
|
|
lastChecked: new Date(0),
|
|
features: DEFAULT_FEATURES,
|
|
};
|
|
})();
|
|
|
|
getPreviousResultPromise
|
|
.finally(() => {
|
|
getPreviousResultPromise = null;
|
|
})
|
|
.catch(() => {});
|
|
|
|
return getPreviousResultPromise;
|
|
};
|
|
|
|
const setPreviousResult = async (previousResult: TPreviousResult) => {
|
|
if (globalThis.window !== undefined) return;
|
|
|
|
try {
|
|
const result = await cache.set(
|
|
getCacheKeys().PREVIOUS_RESULT_CACHE_KEY,
|
|
previousResult,
|
|
CONFIG.CACHE.PREVIOUS_RESULT_TTL_MS
|
|
);
|
|
if (!result.ok) {
|
|
logger.warn({ error: result.error }, "Failed to cache previous result");
|
|
}
|
|
} catch (error) {
|
|
logger.error({ error }, "Failed to set previous result in cache");
|
|
}
|
|
};
|
|
|
|
// Monitoring functions
|
|
const trackFallbackUsage = (level: FallbackLevel) => {
|
|
logger.info(
|
|
{
|
|
fallbackLevel: level,
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
`Using license fallback level: ${level}`
|
|
);
|
|
};
|
|
|
|
const trackApiError = (error: LicenseApiError) => {
|
|
logger.error(
|
|
{
|
|
status: error.status,
|
|
code: error.code,
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
`License API error: ${error.message}`
|
|
);
|
|
};
|
|
|
|
// Validation functions
|
|
const validateFallback = (previousResult: TPreviousResult): boolean => {
|
|
if (!previousResult.features) return false;
|
|
if (previousResult.lastChecked.getTime() === new Date(0).getTime()) return false;
|
|
return true;
|
|
};
|
|
|
|
const validateLicenseDetails = (data: unknown): TEnterpriseLicenseDetails => {
|
|
return LicenseDetailsSchema.parse(data);
|
|
};
|
|
|
|
// Fallback functions
|
|
let memoryCache: {
|
|
data: TEnterpriseLicenseResult;
|
|
timestamp: number;
|
|
} | null = null;
|
|
|
|
const MEMORY_CACHE_TTL_MS = 60 * 1000; // 1 minute memory cache to avoid stampedes and reduce load when Redis is slow
|
|
|
|
let getEnterpriseLicensePromise: Promise<TEnterpriseLicenseResult> | null = null;
|
|
|
|
const getFallbackLevel = (
|
|
liveLicense: TEnterpriseLicenseDetails | null,
|
|
previousResult: TPreviousResult,
|
|
currentTime: Date
|
|
): FallbackLevel => {
|
|
if (liveLicense?.status === "active") return "live";
|
|
if (previousResult.active) {
|
|
const elapsedTime = currentTime.getTime() - previousResult.lastChecked.getTime();
|
|
return elapsedTime < CONFIG.CACHE.GRACE_PERIOD_MS ? "grace" : "default";
|
|
}
|
|
return "default";
|
|
};
|
|
|
|
const handleInitialFailure = async (currentTime: Date): Promise<TEnterpriseLicenseResult> => {
|
|
const initialFailResult: TPreviousResult = {
|
|
active: false,
|
|
features: DEFAULT_FEATURES,
|
|
lastChecked: currentTime,
|
|
};
|
|
await setPreviousResult(initialFailResult);
|
|
return {
|
|
active: false,
|
|
features: DEFAULT_FEATURES,
|
|
lastChecked: currentTime,
|
|
isPendingDowngrade: false,
|
|
fallbackLevel: "default" as const,
|
|
status: "unreachable" as const,
|
|
};
|
|
};
|
|
|
|
// API functions
|
|
let fetchLicensePromise: Promise<TEnterpriseLicenseDetails | null> | null = null;
|
|
|
|
const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpriseLicenseDetails | null> => {
|
|
if (!env.ENTERPRISE_LICENSE_KEY) return null;
|
|
|
|
// Skip license checks during build time
|
|
// eslint-disable-next-line turbo/no-undeclared-env-vars -- NEXT_PHASE is a next.js env variable
|
|
if (process.env.NEXT_PHASE === "phase-production-build") {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const now = new Date();
|
|
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
|
// first millisecond of next year => current year is fully included
|
|
const startOfNextYear = new Date(now.getFullYear() + 1, 0, 1);
|
|
|
|
const startTime = Date.now();
|
|
const [instanceId, responseCount] = await Promise.all([
|
|
// Skip instance ID during E2E tests to avoid license key conflicts
|
|
// as the instance ID changes with each test run
|
|
E2E_TESTING ? null : getInstanceId(),
|
|
prisma.response.count({
|
|
where: {
|
|
createdAt: {
|
|
gte: startOfYear,
|
|
lt: startOfNextYear,
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
const duration = Date.now() - startTime;
|
|
|
|
if (duration > 1000) {
|
|
logger.warn({ duration, responseCount }, "Slow license check prerequisite data fetching (DB count)");
|
|
}
|
|
|
|
// No organization exists, cannot perform license check
|
|
// (skip this check during E2E tests as we intentionally use null)
|
|
if (!E2E_TESTING && !instanceId) return null;
|
|
|
|
const proxyUrl = env.HTTPS_PROXY ?? env.HTTP_PROXY;
|
|
const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), CONFIG.API.TIMEOUT_MS);
|
|
|
|
const payload: Record<string, unknown> = {
|
|
licenseKey: env.ENTERPRISE_LICENSE_KEY,
|
|
usage: { responseCount },
|
|
};
|
|
|
|
if (instanceId) {
|
|
payload.instanceId = instanceId;
|
|
}
|
|
|
|
const res = await fetch(CONFIG.API.ENDPOINT, {
|
|
body: JSON.stringify(payload),
|
|
headers: { "Content-Type": "application/json" },
|
|
method: "POST",
|
|
agent,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
if (res.ok) {
|
|
const responseJson = (await res.json()) as { data: unknown };
|
|
const licenseDetails = validateLicenseDetails(responseJson.data);
|
|
|
|
logger.debug(
|
|
{
|
|
status: licenseDetails.status,
|
|
instanceId: instanceId ?? "not-set",
|
|
responseCount,
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
"License check API response received"
|
|
);
|
|
|
|
return licenseDetails;
|
|
}
|
|
|
|
const error = new LicenseApiError(`License check API responded with status: ${res.status}`, res.status);
|
|
trackApiError(error);
|
|
|
|
// Retry on specific status codes
|
|
if (retryCount < CONFIG.CACHE.MAX_RETRIES && [429, 502, 503, 504].includes(res.status)) {
|
|
await sleep(CONFIG.CACHE.RETRY_DELAY_MS * Math.pow(2, retryCount));
|
|
return fetchLicenseFromServerInternal(retryCount + 1);
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
if (error instanceof LicenseApiError) {
|
|
throw error;
|
|
}
|
|
logger.error(error, "Error while fetching license from server");
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export const fetchLicense = async (): Promise<TEnterpriseLicenseDetails | null> => {
|
|
if (!env.ENTERPRISE_LICENSE_KEY) return null;
|
|
|
|
// Skip license checks during build time - check before cache access
|
|
// eslint-disable-next-line turbo/no-undeclared-env-vars -- NEXT_PHASE is a next.js env variable
|
|
if (process.env.NEXT_PHASE === "phase-production-build") {
|
|
return null;
|
|
}
|
|
|
|
if (fetchLicensePromise) {
|
|
return fetchLicensePromise;
|
|
}
|
|
|
|
fetchLicensePromise = (async () => {
|
|
return await cache.withCache(
|
|
async () => {
|
|
return await fetchLicenseFromServerInternal();
|
|
},
|
|
getCacheKeys().FETCH_LICENSE_CACHE_KEY,
|
|
CONFIG.CACHE.FETCH_LICENSE_TTL_MS
|
|
);
|
|
})();
|
|
|
|
fetchLicensePromise
|
|
.finally(() => {
|
|
fetchLicensePromise = null;
|
|
})
|
|
.catch(() => {});
|
|
|
|
return fetchLicensePromise;
|
|
};
|
|
|
|
export const getEnterpriseLicense = reactCache(async (): Promise<TEnterpriseLicenseResult> => {
|
|
if (
|
|
process.env.NODE_ENV !== "test" &&
|
|
memoryCache &&
|
|
Date.now() - memoryCache.timestamp < MEMORY_CACHE_TTL_MS
|
|
) {
|
|
return memoryCache.data;
|
|
}
|
|
|
|
if (getEnterpriseLicensePromise) return getEnterpriseLicensePromise;
|
|
|
|
getEnterpriseLicensePromise = (async () => {
|
|
validateConfig();
|
|
|
|
if (!env.ENTERPRISE_LICENSE_KEY || env.ENTERPRISE_LICENSE_KEY.length === 0) {
|
|
return {
|
|
active: false,
|
|
features: null,
|
|
lastChecked: new Date(),
|
|
isPendingDowngrade: false,
|
|
fallbackLevel: "default" as const,
|
|
status: "no-license" as const,
|
|
};
|
|
}
|
|
const currentTime = new Date();
|
|
const [liveLicenseDetails, previousResult] = await Promise.all([fetchLicense(), getPreviousResult()]);
|
|
const fallbackLevel = getFallbackLevel(liveLicenseDetails, previousResult, currentTime);
|
|
|
|
trackFallbackUsage(fallbackLevel);
|
|
|
|
let currentLicenseState: TPreviousResult | undefined;
|
|
|
|
switch (fallbackLevel) {
|
|
case "live": {
|
|
if (!liveLicenseDetails) throw new Error("Invalid state: live license expected");
|
|
currentLicenseState = {
|
|
active: liveLicenseDetails.status === "active",
|
|
features: liveLicenseDetails.features,
|
|
lastChecked: currentTime,
|
|
};
|
|
|
|
// Only update previous result if it's actually different or if it's old (1 hour)
|
|
// This prevents hammering Redis on every request when the license is active
|
|
if (
|
|
!previousResult.active ||
|
|
previousResult.active !== currentLicenseState.active ||
|
|
currentTime.getTime() - previousResult.lastChecked.getTime() > 60 * 60 * 1000
|
|
) {
|
|
await setPreviousResult(currentLicenseState);
|
|
}
|
|
|
|
const liveResult: TEnterpriseLicenseResult = {
|
|
active: currentLicenseState.active,
|
|
features: currentLicenseState.features,
|
|
lastChecked: currentTime,
|
|
isPendingDowngrade: false,
|
|
fallbackLevel: "live" as const,
|
|
status: liveLicenseDetails.status,
|
|
};
|
|
memoryCache = { data: liveResult, timestamp: Date.now() };
|
|
return liveResult;
|
|
}
|
|
|
|
case "grace": {
|
|
if (!validateFallback(previousResult)) {
|
|
return await handleInitialFailure(currentTime);
|
|
}
|
|
const graceResult: TEnterpriseLicenseResult = {
|
|
active: previousResult.active,
|
|
features: previousResult.features,
|
|
lastChecked: previousResult.lastChecked,
|
|
isPendingDowngrade: true,
|
|
fallbackLevel: "grace" as const,
|
|
status: (liveLicenseDetails?.status as TEnterpriseLicenseStatusReturn) ?? "unreachable",
|
|
};
|
|
memoryCache = { data: graceResult, timestamp: Date.now() };
|
|
return graceResult;
|
|
}
|
|
|
|
case "default": {
|
|
if (liveLicenseDetails?.status === "expired") {
|
|
const expiredResult: TEnterpriseLicenseResult = {
|
|
active: false,
|
|
features: DEFAULT_FEATURES,
|
|
lastChecked: currentTime,
|
|
isPendingDowngrade: false,
|
|
fallbackLevel: "default" as const,
|
|
status: "expired" as const,
|
|
};
|
|
memoryCache = { data: expiredResult, timestamp: Date.now() };
|
|
return expiredResult;
|
|
}
|
|
const failResult = await handleInitialFailure(currentTime);
|
|
memoryCache = { data: failResult, timestamp: Date.now() };
|
|
return failResult;
|
|
}
|
|
}
|
|
|
|
const finalFailResult = await handleInitialFailure(currentTime);
|
|
memoryCache = { data: finalFailResult, timestamp: Date.now() };
|
|
return finalFailResult;
|
|
})();
|
|
|
|
getEnterpriseLicensePromise
|
|
.finally(() => {
|
|
getEnterpriseLicensePromise = null;
|
|
})
|
|
.catch(() => {});
|
|
|
|
return getEnterpriseLicensePromise;
|
|
});
|
|
|
|
export const getLicenseFeatures = async (): Promise<TEnterpriseLicenseFeatures | null> => {
|
|
try {
|
|
const licenseState = await getEnterpriseLicense();
|
|
return licenseState.active ? licenseState.features : null;
|
|
} catch (e) {
|
|
logger.error(e, "Error getting license features");
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// All permission checking functions and their helpers have been moved to utils.ts
|