- generateLicenseKey() now accepts tier parameter and prefixes keys: T=Trial, B=Basic, P=Professional, E=Enterprise - Added detectLicenseTier() to extract tier from key prefix - Updated isValidLicenseFormat() regex to accept tier prefixes - Simplified activateLicense() to use detectLicenseTier() - Added license_generate API endpoint for generating sample keys - Added "Generate Sample License Key" UI section with tier selector - Added copy/use buttons for generated keys Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
420 lines
12 KiB
PHP
420 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* License Management System
|
|
*
|
|
* Handles license validation, feature flags, and usage limits for ISP IP Manager
|
|
*
|
|
* Copyright (c) 2025 Purple Computing Ltd. All rights reserved.
|
|
*/
|
|
|
|
// License tier definitions
|
|
define('LICENSE_TRIAL', 'trial');
|
|
define('LICENSE_BASIC', 'basic');
|
|
define('LICENSE_PROFESSIONAL', 'professional');
|
|
define('LICENSE_ENTERPRISE', 'enterprise');
|
|
|
|
// License limits by tier
|
|
$LICENSE_LIMITS = [
|
|
LICENSE_TRIAL => [
|
|
'max_entries' => 100,
|
|
'max_users' => 2,
|
|
'features' => ['basic_crud', 'csv_export'],
|
|
'name' => 'Trial',
|
|
'duration_days' => 14
|
|
],
|
|
LICENSE_BASIC => [
|
|
'max_entries' => 500,
|
|
'max_users' => 5,
|
|
'features' => ['basic_crud', 'csv_export', 'webhooks', 'audit_log'],
|
|
'name' => 'Basic'
|
|
],
|
|
LICENSE_PROFESSIONAL => [
|
|
'max_entries' => 2500,
|
|
'max_users' => 15,
|
|
'features' => ['basic_crud', 'csv_export', 'webhooks', 'audit_log', 'ip_enrichment', 'whitelabel', 'ptr_records'],
|
|
'name' => 'Professional'
|
|
],
|
|
LICENSE_ENTERPRISE => [
|
|
'max_entries' => -1, // Unlimited
|
|
'max_users' => -1, // Unlimited
|
|
'features' => ['basic_crud', 'csv_export', 'webhooks', 'audit_log', 'ip_enrichment', 'whitelabel', 'ptr_records', 'api_access', 'priority_support'],
|
|
'name' => 'Enterprise'
|
|
]
|
|
];
|
|
|
|
/**
|
|
* Tier prefix mapping for license keys
|
|
* First segment encodes the tier: Txxxx, Bxxxx, Pxxxx, Exxxx
|
|
*/
|
|
define('LICENSE_TIER_PREFIXES', [
|
|
LICENSE_TRIAL => 'T',
|
|
LICENSE_BASIC => 'B',
|
|
LICENSE_PROFESSIONAL => 'P',
|
|
LICENSE_ENTERPRISE => 'E'
|
|
]);
|
|
|
|
/**
|
|
* Generate a new license key for a given tier
|
|
* Format: IPMAN-TYYY-XXXX-XXXX-XXXX
|
|
* Where T is the tier prefix (T=Trial, B=Basic, P=Professional, E=Enterprise)
|
|
* and the rest are random hex characters
|
|
*/
|
|
function generateLicenseKey($tier = LICENSE_TRIAL) {
|
|
$prefixes = LICENSE_TIER_PREFIXES;
|
|
$prefix = $prefixes[$tier] ?? 'T';
|
|
|
|
// First segment: tier prefix + 3 random hex chars
|
|
$seg1 = $prefix . strtoupper(bin2hex(random_bytes(1))) . strtoupper(dechex(random_int(0, 15)));
|
|
|
|
// Remaining segments: random hex
|
|
$segments = [$seg1];
|
|
for ($i = 0; $i < 3; $i++) {
|
|
$segments[] = strtoupper(bin2hex(random_bytes(2)));
|
|
}
|
|
return 'IPMAN-' . implode('-', $segments);
|
|
}
|
|
|
|
/**
|
|
* Get license secret (from environment or generate)
|
|
*/
|
|
function getLicenseSecret() {
|
|
$secret = getenv('LICENSE_SECRET');
|
|
if (empty($secret)) {
|
|
$secret = 'purple-computing-isp-ip-manager-2025';
|
|
}
|
|
return $secret;
|
|
}
|
|
|
|
/**
|
|
* Validate a license key format
|
|
* Accepts: IPMAN-XXXX-XXXX-XXXX-XXXX where X is alphanumeric hex
|
|
*/
|
|
function isValidLicenseFormat($key) {
|
|
return preg_match('/^IPMAN-[A-Z0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}$/i', $key);
|
|
}
|
|
|
|
/**
|
|
* Detect license tier from a license key
|
|
*/
|
|
function detectLicenseTier($key) {
|
|
if (!isValidLicenseFormat($key)) {
|
|
return LICENSE_TRIAL;
|
|
}
|
|
|
|
// First char of first segment after IPMAN-
|
|
$tierChar = strtoupper(substr($key, 6, 1));
|
|
|
|
$prefixes = array_flip(LICENSE_TIER_PREFIXES);
|
|
return $prefixes[$tierChar] ?? LICENSE_TRIAL;
|
|
}
|
|
|
|
/**
|
|
* Get current license info from database
|
|
*/
|
|
function getLicenseInfo($db = null) {
|
|
if (!$db && function_exists('getDB')) {
|
|
$db = getDB();
|
|
}
|
|
|
|
if (!$db) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$stmt = $db->query("SELECT * FROM license_info WHERE is_active = 1 ORDER BY id DESC LIMIT 1");
|
|
return $stmt->fetch(PDO::FETCH_ASSOC);
|
|
} catch (Exception $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if license is valid and not expired
|
|
*/
|
|
function isLicenseValid($db = null) {
|
|
$license = getLicenseInfo($db);
|
|
|
|
if (!$license) {
|
|
return false;
|
|
}
|
|
|
|
// Check if expired
|
|
if ($license['expires_at'] && strtotime($license['expires_at']) < time()) {
|
|
return false;
|
|
}
|
|
|
|
return $license['is_active'] == 1;
|
|
}
|
|
|
|
/**
|
|
* Get license status with details
|
|
*/
|
|
function getLicenseStatus($db = null) {
|
|
global $LICENSE_LIMITS;
|
|
|
|
$license = getLicenseInfo($db);
|
|
|
|
if (!$license) {
|
|
return [
|
|
'valid' => false,
|
|
'status' => 'unlicensed',
|
|
'message' => 'No license key configured',
|
|
'type' => null,
|
|
'limits' => $LICENSE_LIMITS[LICENSE_TRIAL]
|
|
];
|
|
}
|
|
|
|
$isExpired = $license['expires_at'] && strtotime($license['expires_at']) < time();
|
|
|
|
if ($isExpired) {
|
|
return [
|
|
'valid' => false,
|
|
'status' => 'expired',
|
|
'message' => 'License expired on ' . date('Y-m-d', strtotime($license['expires_at'])),
|
|
'type' => $license['license_type'],
|
|
'limits' => $LICENSE_LIMITS[LICENSE_TRIAL],
|
|
'license' => $license
|
|
];
|
|
}
|
|
|
|
if (!$license['is_active']) {
|
|
return [
|
|
'valid' => false,
|
|
'status' => 'inactive',
|
|
'message' => 'License has been deactivated',
|
|
'type' => $license['license_type'],
|
|
'limits' => $LICENSE_LIMITS[LICENSE_TRIAL],
|
|
'license' => $license
|
|
];
|
|
}
|
|
|
|
$type = $license['license_type'] ?: LICENSE_TRIAL;
|
|
$limits = $LICENSE_LIMITS[$type] ?? $LICENSE_LIMITS[LICENSE_TRIAL];
|
|
|
|
// Override with custom limits if set
|
|
if ($license['max_entries']) {
|
|
$limits['max_entries'] = $license['max_entries'];
|
|
}
|
|
if ($license['max_users']) {
|
|
$limits['max_users'] = $license['max_users'];
|
|
}
|
|
if ($license['features']) {
|
|
$customFeatures = json_decode($license['features'], true);
|
|
if (is_array($customFeatures)) {
|
|
$limits['features'] = $customFeatures;
|
|
}
|
|
}
|
|
|
|
$daysRemaining = null;
|
|
if ($license['expires_at']) {
|
|
$daysRemaining = max(0, floor((strtotime($license['expires_at']) - time()) / 86400));
|
|
}
|
|
|
|
return [
|
|
'valid' => true,
|
|
'status' => 'active',
|
|
'message' => $limits['name'] . ' License',
|
|
'type' => $type,
|
|
'limits' => $limits,
|
|
'license' => $license,
|
|
'days_remaining' => $daysRemaining,
|
|
'expires_at' => $license['expires_at']
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Check if a specific feature is available
|
|
*/
|
|
function hasFeature($feature, $db = null) {
|
|
$status = getLicenseStatus($db);
|
|
|
|
// Always allow basic features for unlicensed/trial
|
|
$basicFeatures = ['basic_crud', 'csv_export'];
|
|
if (in_array($feature, $basicFeatures)) {
|
|
return true;
|
|
}
|
|
|
|
if (!$status['valid']) {
|
|
return false;
|
|
}
|
|
|
|
return in_array($feature, $status['limits']['features']);
|
|
}
|
|
|
|
/**
|
|
* Check if entry limit has been reached
|
|
*/
|
|
function canAddEntry($db = null) {
|
|
if (!$db && function_exists('getDB')) {
|
|
$db = getDB();
|
|
}
|
|
|
|
$status = getLicenseStatus($db);
|
|
$maxEntries = $status['limits']['max_entries'];
|
|
|
|
// -1 means unlimited
|
|
if ($maxEntries == -1) {
|
|
return ['allowed' => true, 'current' => 0, 'max' => -1];
|
|
}
|
|
|
|
try {
|
|
$stmt = $db->query("SELECT COUNT(*) FROM geofeed_entries");
|
|
$current = (int)$stmt->fetchColumn();
|
|
|
|
return [
|
|
'allowed' => $current < $maxEntries,
|
|
'current' => $current,
|
|
'max' => $maxEntries
|
|
];
|
|
} catch (Exception $e) {
|
|
return ['allowed' => true, 'current' => 0, 'max' => $maxEntries];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if user limit has been reached
|
|
*/
|
|
function canAddUser($db = null) {
|
|
if (!$db && function_exists('getDB')) {
|
|
$db = getDB();
|
|
}
|
|
|
|
$status = getLicenseStatus($db);
|
|
$maxUsers = $status['limits']['max_users'];
|
|
|
|
// -1 means unlimited
|
|
if ($maxUsers == -1) {
|
|
return ['allowed' => true, 'current' => 0, 'max' => -1];
|
|
}
|
|
|
|
try {
|
|
$stmt = $db->query("SELECT COUNT(*) FROM admin_users WHERE active = 1");
|
|
$current = (int)$stmt->fetchColumn();
|
|
|
|
return [
|
|
'allowed' => $current < $maxUsers,
|
|
'current' => $current,
|
|
'max' => $maxUsers
|
|
];
|
|
} catch (Exception $e) {
|
|
return ['allowed' => true, 'current' => 0, 'max' => $maxUsers];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Activate a license key
|
|
*/
|
|
function activateLicense($db, $licenseKey, $licenseeName, $licenseeEmail) {
|
|
if (!isValidLicenseFormat($licenseKey)) {
|
|
return ['success' => false, 'error' => 'Invalid license key format. Expected: IPMAN-XXXX-XXXX-XXXX-XXXX'];
|
|
}
|
|
|
|
// Detect tier from key prefix
|
|
$licenseType = detectLicenseTier($licenseKey);
|
|
|
|
// Set expiry based on tier
|
|
switch ($licenseType) {
|
|
case LICENSE_BASIC:
|
|
$expiresAt = date('Y-m-d H:i:s', strtotime('+1 year'));
|
|
break;
|
|
case LICENSE_PROFESSIONAL:
|
|
$expiresAt = date('Y-m-d H:i:s', strtotime('+1 year'));
|
|
break;
|
|
case LICENSE_ENTERPRISE:
|
|
$expiresAt = null; // No expiry
|
|
break;
|
|
default: // Trial
|
|
$expiresAt = date('Y-m-d H:i:s', strtotime('+14 days'));
|
|
break;
|
|
}
|
|
|
|
global $LICENSE_LIMITS;
|
|
$limits = $LICENSE_LIMITS[$licenseType];
|
|
|
|
try {
|
|
// Deactivate any existing licenses
|
|
$db->exec("UPDATE license_info SET is_active = 0");
|
|
|
|
// Insert new license
|
|
$stmt = $db->prepare("
|
|
INSERT INTO license_info (license_key, licensee_name, licensee_email, license_type, max_entries, max_users, features, expires_at, is_active)
|
|
VALUES (:key, :name, :email, :type, :max_entries, :max_users, :features, :expires, 1)
|
|
");
|
|
|
|
$stmt->execute([
|
|
':key' => $licenseKey,
|
|
':name' => $licenseeName,
|
|
':email' => $licenseeEmail,
|
|
':type' => $licenseType,
|
|
':max_entries' => $limits['max_entries'],
|
|
':max_users' => $limits['max_users'],
|
|
':features' => json_encode($limits['features']),
|
|
':expires' => $expiresAt
|
|
]);
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => 'License activated successfully',
|
|
'type' => $licenseType,
|
|
'expires_at' => $expiresAt
|
|
];
|
|
} catch (Exception $e) {
|
|
return ['success' => false, 'error' => 'Failed to activate license: ' . $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deactivate current license
|
|
*/
|
|
function deactivateLicense($db) {
|
|
try {
|
|
$db->exec("UPDATE license_info SET is_active = 0");
|
|
return ['success' => true, 'message' => 'License deactivated'];
|
|
} catch (Exception $e) {
|
|
return ['success' => false, 'error' => 'Failed to deactivate license'];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get usage statistics for license
|
|
*/
|
|
function getLicenseUsage($db = null) {
|
|
if (!$db && function_exists('getDB')) {
|
|
$db = getDB();
|
|
}
|
|
|
|
$status = getLicenseStatus($db);
|
|
|
|
$entryCount = 0;
|
|
$userCount = 0;
|
|
|
|
try {
|
|
$stmt = $db->query("SELECT COUNT(*) FROM geofeed_entries");
|
|
$entryCount = (int)$stmt->fetchColumn();
|
|
|
|
$stmt = $db->query("SELECT COUNT(*) FROM admin_users WHERE active = 1");
|
|
$userCount = (int)$stmt->fetchColumn();
|
|
} catch (Exception $e) {
|
|
// Ignore
|
|
}
|
|
|
|
$maxEntries = $status['limits']['max_entries'];
|
|
$maxUsers = $status['limits']['max_users'];
|
|
|
|
return [
|
|
'entries' => [
|
|
'current' => $entryCount,
|
|
'max' => $maxEntries,
|
|
'percentage' => $maxEntries > 0 ? round(($entryCount / $maxEntries) * 100) : 0,
|
|
'unlimited' => $maxEntries == -1
|
|
],
|
|
'users' => [
|
|
'current' => $userCount,
|
|
'max' => $maxUsers,
|
|
'percentage' => $maxUsers > 0 ? round(($userCount / $maxUsers) * 100) : 0,
|
|
'unlimited' => $maxUsers == -1
|
|
],
|
|
'license' => $status
|
|
];
|
|
}
|