mirror of
https://github.com/documenso/documenso.git
synced 2026-07-11 21:45:18 +10:00
245 lines
6.9 KiB
TypeScript
245 lines
6.9 KiB
TypeScript
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
import { prisma } from '@documenso/prisma';
|
|
|
|
import { IS_BILLING_ENABLED } from '../../constants/app';
|
|
import type { TLicenseClaim } from '../../types/license';
|
|
import {
|
|
LICENSE_FILE_NAME,
|
|
type TCachedLicense,
|
|
type TLicenseResponse,
|
|
ZCachedLicenseSchema,
|
|
ZLicenseResponseSchema,
|
|
} from '../../types/license';
|
|
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '../../types/subscription';
|
|
import { env } from '../../utils/env';
|
|
|
|
const LICENSE_KEY = env('NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY');
|
|
const LICENSE_SERVER_URL = env('INTERNAL_OVERRIDE_LICENSE_SERVER_URL') || 'https://license.documenso.com';
|
|
|
|
declare global {
|
|
// eslint-disable-next-line no-var
|
|
var __documenso_license_client__: LicenseClient | undefined;
|
|
}
|
|
|
|
export class LicenseClient {
|
|
/**
|
|
* We cache the license in memory incase there is permission issues with
|
|
* retrieving the license from the local file system.
|
|
*/
|
|
private cachedLicense: TCachedLicense | null = null;
|
|
|
|
private constructor() {}
|
|
|
|
/**
|
|
* Start the license client.
|
|
*
|
|
* This will ping the license server with the configured license key and store
|
|
* the response locally in a JSON file.
|
|
*
|
|
* Uses globalThis to store the singleton instance so that it's shared across
|
|
* different bundles (e.g. Hono and Remix) at runtime.
|
|
*/
|
|
public static async start(): Promise<void> {
|
|
if (globalThis.__documenso_license_client__) {
|
|
return;
|
|
}
|
|
|
|
const instance = new LicenseClient();
|
|
|
|
globalThis.__documenso_license_client__ = instance;
|
|
|
|
try {
|
|
await instance.initialize();
|
|
} catch (err) {
|
|
// Do nothing.
|
|
console.error('[License] Failed to verify license:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the current license client instance.
|
|
*
|
|
* Returns the shared instance from globalThis, ensuring both Hono and Remix
|
|
* bundles access the same instance.
|
|
*/
|
|
public static getInstance(): LicenseClient | null {
|
|
return globalThis.__documenso_license_client__ ?? null;
|
|
}
|
|
|
|
public async getCachedLicense(): Promise<TCachedLicense | null> {
|
|
if (this.cachedLicense) {
|
|
return this.cachedLicense;
|
|
}
|
|
|
|
const localLicenseFile = await this.loadFromFile();
|
|
|
|
return localLicenseFile;
|
|
}
|
|
|
|
/**
|
|
* Force resync the license from the license server.
|
|
*
|
|
* This will re-ping the license server and update the cached license file.
|
|
*/
|
|
public async resync(): Promise<void> {
|
|
await this.initialize();
|
|
}
|
|
|
|
private async initialize(): Promise<void> {
|
|
console.log('[License] Checking license with server...');
|
|
|
|
const cachedLicense = await this.loadFromFile();
|
|
|
|
if (cachedLicense) {
|
|
this.cachedLicense = cachedLicense;
|
|
}
|
|
|
|
let response: TLicenseResponse | null = null;
|
|
|
|
try {
|
|
response = await this.pingLicenseServer();
|
|
} catch (err) {
|
|
// If server is not responding, or erroring, use the cached license.
|
|
console.warn('[License] License server not responding, using cached license.');
|
|
console.error(err);
|
|
return;
|
|
}
|
|
|
|
const allowedFlags = response?.data?.flags || {};
|
|
|
|
// Check for unauthorized flag usage
|
|
const unauthorizedFlagUsage = await this.checkUnauthorizedFlagUsage(allowedFlags);
|
|
|
|
if (unauthorizedFlagUsage) {
|
|
console.warn('[License] Found unauthorized flag usage.');
|
|
}
|
|
|
|
let status: TCachedLicense['derivedStatus'] = 'NOT_FOUND';
|
|
|
|
if (response?.data?.status) {
|
|
status = response.data.status;
|
|
}
|
|
|
|
if (unauthorizedFlagUsage) {
|
|
status = 'UNAUTHORIZED';
|
|
}
|
|
|
|
const data: TCachedLicense = {
|
|
lastChecked: new Date().toISOString(),
|
|
license: response?.data || null,
|
|
requestedLicenseKey: LICENSE_KEY,
|
|
unauthorizedFlagUsage,
|
|
derivedStatus: status,
|
|
};
|
|
|
|
this.cachedLicense = data;
|
|
await this.saveToFile(data);
|
|
|
|
console.log('[License] License check completed successfully.');
|
|
console.log(`[License] Unauthorized Flag Usage: ${unauthorizedFlagUsage ? 'Yes' : 'No'}`);
|
|
console.log(`[License] Derived Status: ${status}`);
|
|
console.log(`[License] Status: ${response?.data?.status}`);
|
|
console.log(`[License] Flags: ${JSON.stringify(allowedFlags)}`);
|
|
}
|
|
|
|
/**
|
|
* Ping the license server to get the license response.
|
|
*
|
|
* If license not found returns null.
|
|
*/
|
|
private async pingLicenseServer(): Promise<TLicenseResponse | null> {
|
|
if (!LICENSE_KEY) {
|
|
return null;
|
|
}
|
|
|
|
const endpoint = new URL('api/license', LICENSE_SERVER_URL).toString();
|
|
|
|
const response = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ license: LICENSE_KEY }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`License server returned ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
return ZLicenseResponseSchema.parse(data);
|
|
}
|
|
|
|
private async saveToFile(data: TCachedLicense): Promise<void> {
|
|
const licenseFilePath = path.join(process.cwd(), LICENSE_FILE_NAME);
|
|
|
|
try {
|
|
await fs.writeFile(licenseFilePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
} catch (error) {
|
|
console.error('[License] Failed to save license file:', error);
|
|
}
|
|
}
|
|
|
|
private async loadFromFile(): Promise<TCachedLicense | null> {
|
|
const licenseFilePath = path.join(process.cwd(), LICENSE_FILE_NAME);
|
|
|
|
try {
|
|
const fileContents = await fs.readFile(licenseFilePath, 'utf-8');
|
|
|
|
return ZCachedLicenseSchema.parse(JSON.parse(fileContents));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if any organisation claims are using flags that are not permitted by the current license.
|
|
*/
|
|
private async checkUnauthorizedFlagUsage(licenseFlags: Partial<TLicenseClaim>): Promise<boolean> {
|
|
// Get flags that are NOT permitted by the license by subtracting the allowed flags from the license flags.
|
|
const disallowedFlags = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).filter(
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
(flag) => flag.isEnterprise && !licenseFlags[flag.key as keyof TLicenseClaim],
|
|
);
|
|
|
|
let unauthorizedFlagUsage = false;
|
|
|
|
if (IS_BILLING_ENABLED() && !licenseFlags.billing) {
|
|
unauthorizedFlagUsage = true;
|
|
}
|
|
|
|
try {
|
|
const organisationWithUnauthorizedFlags = await prisma.organisationClaim.findFirst({
|
|
where: {
|
|
OR: disallowedFlags.map((flag) => ({
|
|
flags: {
|
|
path: [flag.key],
|
|
equals: true,
|
|
},
|
|
})),
|
|
},
|
|
select: {
|
|
id: true,
|
|
organisation: {
|
|
select: {
|
|
id: true,
|
|
},
|
|
},
|
|
flags: true,
|
|
},
|
|
});
|
|
|
|
if (organisationWithUnauthorizedFlags) {
|
|
unauthorizedFlagUsage = true;
|
|
}
|
|
} catch (error) {
|
|
console.error('[License] Failed to check unauthorized flag usage:', error);
|
|
}
|
|
|
|
return unauthorizedFlagUsage;
|
|
}
|
|
}
|