mirror of
https://github.com/CloakHQ/CloakBrowser.git
synced 2026-06-23 11:41:46 +02:00
219 lines
5.9 KiB
TypeScript
219 lines
5.9 KiB
TypeScript
/**
|
|
* License validation and caching for CloakBrowser Pro.
|
|
* Mirrors Python cloakbrowser/license.py.
|
|
*
|
|
* Handles license key resolution, server validation with local caching,
|
|
* and Pro version checks.
|
|
*/
|
|
|
|
import { createHash } from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
import { getCacheDir } from "./config.js";
|
|
|
|
const VALIDATE_URL = "https://cloakbrowser.dev/api/license/validate";
|
|
const PRO_VERSION_URL = "https://cloakbrowser.dev/api/download/version";
|
|
|
|
const LICENSE_CACHE_TTL_MS = 86_400_000; // 24 hours
|
|
const PRO_VERSION_CHECK_INTERVAL_MS = 3_600_000; // 1 hour
|
|
|
|
export interface LicenseInfo {
|
|
valid: boolean;
|
|
plan: string;
|
|
expires: string | null;
|
|
}
|
|
|
|
/**
|
|
* Resolve the license key: explicit param > env var > file > undefined.
|
|
*/
|
|
export function resolveLicenseKey(licenseKey?: string): string | undefined {
|
|
const trimmed = licenseKey?.trim();
|
|
if (trimmed) return trimmed;
|
|
const envKey = (process.env.CLOAKBROWSER_LICENSE_KEY ?? "").trim();
|
|
if (envKey) return envKey;
|
|
try {
|
|
const keyFile = path.join(getCacheDir(), "license.key");
|
|
const content = fs.readFileSync(keyFile, "utf-8").trim();
|
|
if (content) return content;
|
|
} catch {
|
|
// File doesn't exist or unreadable
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Validate a license key with the CloakBrowser server.
|
|
*
|
|
* Checks a local file cache first (24h TTL). Falls back to stale
|
|
* cache if the server is unreachable.
|
|
*
|
|
* Returns LicenseInfo if validation succeeded, null on total failure.
|
|
*/
|
|
export async function validateLicense(licenseKey: string): Promise<LicenseInfo | null> {
|
|
const cachePath = path.join(getCacheDir(), ".license_cache");
|
|
const keySha = createHash("sha256").update(licenseKey).digest("hex");
|
|
|
|
const cached = readCache(cachePath, keySha);
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const resp = await fetch(VALIDATE_URL, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ license_key: licenseKey }),
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
|
}
|
|
|
|
const data = (await resp.json()) as Record<string, unknown>;
|
|
|
|
const info: LicenseInfo = {
|
|
valid: Boolean(data.valid ?? false),
|
|
plan: String(data.plan ?? "solo"),
|
|
expires: data.expires != null ? String(data.expires) : null,
|
|
};
|
|
|
|
if (info.valid) {
|
|
writeCache(cachePath, keySha, info);
|
|
}
|
|
return info;
|
|
} catch (e) {
|
|
console.warn(
|
|
`[cloakbrowser] License validation request failed: ${e instanceof Error ? e.message : e}`
|
|
);
|
|
|
|
// Fall back to stale cache
|
|
const stale = readCache(cachePath, keySha, true);
|
|
if (stale) {
|
|
console.warn("[cloakbrowser] Using cached license validation (server unreachable)");
|
|
return stale;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the latest Pro binary version from the server.
|
|
* Rate-limited to 1 call per hour via a marker file.
|
|
*/
|
|
export async function getProLatestVersion(): Promise<string | null> {
|
|
const marker = path.join(getCacheDir(), ".last_pro_version_check");
|
|
|
|
try {
|
|
if (fs.existsSync(marker)) {
|
|
const stats = fs.statSync(marker);
|
|
const age = Date.now() - stats.mtimeMs;
|
|
if (age < PRO_VERSION_CHECK_INTERVAL_MS) {
|
|
const content = fs.readFileSync(marker, "utf-8").trim();
|
|
return content || null;
|
|
}
|
|
}
|
|
} catch {
|
|
// Marker unreadable — proceed with fetch
|
|
}
|
|
|
|
try {
|
|
const resp = await fetch(PRO_VERSION_URL, {
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
|
}
|
|
|
|
const data = (await resp.json()) as Record<string, unknown>;
|
|
const version = data.version != null ? String(data.version) : null;
|
|
if (!version) return null;
|
|
|
|
try {
|
|
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
|
fs.writeFileSync(marker, version);
|
|
} catch {
|
|
// Non-fatal
|
|
}
|
|
|
|
return version;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cache helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface CacheData {
|
|
key_sha256: string;
|
|
valid: boolean;
|
|
plan: string;
|
|
expires: string | null;
|
|
validated_at: number;
|
|
}
|
|
|
|
function readCache(
|
|
cachePath: string,
|
|
keySha: string,
|
|
ignoreTtl = false,
|
|
): LicenseInfo | null {
|
|
try {
|
|
if (!fs.existsSync(cachePath)) return null;
|
|
|
|
const data = JSON.parse(fs.readFileSync(cachePath, "utf-8")) as CacheData;
|
|
|
|
if (data.key_sha256 !== keySha) return null;
|
|
|
|
if (!ignoreTtl) {
|
|
const validatedAt = data.validated_at ?? 0;
|
|
// A non-numeric validated_at (corrupted cache) is treated as absent rather
|
|
// than coercing to NaN and silently trusting the entry.
|
|
if (!Number.isFinite(validatedAt) || Date.now() - validatedAt * 1000 > LICENSE_CACHE_TTL_MS) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if (data.expires) {
|
|
try {
|
|
if (new Date(data.expires).getTime() < Date.now()) {
|
|
return { valid: false, plan: String(data.plan ?? "solo"), expires: data.expires };
|
|
}
|
|
} catch {
|
|
// unparseable date — skip check
|
|
}
|
|
}
|
|
|
|
return {
|
|
valid: Boolean(data.valid ?? false),
|
|
plan: String(data.plan ?? "solo"),
|
|
expires: data.expires ?? null,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeCache(cachePath: string, keySha: string, info: LicenseInfo): void {
|
|
try {
|
|
const dir = path.dirname(cachePath);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const tmpPath = cachePath + ".tmp";
|
|
fs.writeFileSync(
|
|
tmpPath,
|
|
JSON.stringify({
|
|
key_sha256: keySha,
|
|
valid: info.valid,
|
|
plan: info.plan,
|
|
expires: info.expires,
|
|
validated_at: Date.now() / 1000,
|
|
}),
|
|
);
|
|
fs.renameSync(tmpPath, cachePath);
|
|
} catch {
|
|
// Non-fatal
|
|
}
|
|
}
|