One activation hub for all your apps.
+This folder is isolated from the legacy license_manager/ code, auto-creates its own multi-app tables, and can be copied to another server later without rewriting the rest of your app first.
diff --git a/central_license_manager/README.md b/central_license_manager/README.md new file mode 100644 index 0000000..ae77d9a --- /dev/null +++ b/central_license_manager/README.md @@ -0,0 +1,76 @@ +# Central License Manager + +This folder is a standalone multi-app activation manager that lives separately from the legacy `license_manager/` code. + +## What it includes + +- `index.php` — landing page + JSON API endpoints +- `manage.php` — admin dashboard for apps, licenses, and activations +- `install.php` — schema check / auto-install page +- `config.php` — local config and move-to-another-server settings +- `bootstrap.php` — shared DB, schema, and helper logic + +## Tables it manages + +- `clm_apps` +- `clm_licenses` +- `clm_activations` + +## API endpoints + +Use the folder base URL as the app setting: + +```text +LICENSE_API_URL=https://your-domain.example/central_license_manager +``` + +Then the client will call: + +- `index.php?action=activate` +- `index.php?action=verify` +- `index.php?action=deactivate` +- `index.php?action=health` + +Admin/owner endpoints require the secret: + +- `index.php?action=issue` +- `index.php?action=list` +- `index.php?action=apps` +- `index.php?action=update` + +## Moving this folder later + +1. Copy the whole `central_license_manager/` directory to the new server. +2. Set `CLM_DB_HOST`, `CLM_DB_NAME`, `CLM_DB_USER`, `CLM_DB_PASS`. +3. Set `CLM_API_SECRET` and `CLM_ADMIN_PASSWORD`. +4. Open `install.php` once to auto-create the tables. +5. Point each client app to the new base URL. + +## Compatibility note + +The API currently returns both: + +- `max_activations` +- `allowed_activations` + +That keeps older client code working while you gradually upgrade each app to send a dedicated `app_slug`. + +## Current repo integration + +This repo's `lib/LicenseService.php` now auto-detects this folder when `LICENSE_API_URL` is not set and uses it as the activation authority. + +Default client values in this app: + +- `LICENSE_APP_SLUG=bilingual-accounting` +- `LICENSE_APP_NAME=Bilingual Accounting` + +You can override them per app with environment variables: + +- `LICENSE_API_URL` +- `LICENSE_API_SECRET` +- `LICENSE_APP_SLUG` +- `LICENSE_APP_NAME` +- `LICENSE_PRODUCT_NAME` +- `LICENSE_PRODUCT_VERSION` + +So when you move `central_license_manager/` to another server later, you only need to point each app to the new base URL and set the right app slug/name. diff --git a/central_license_manager/bootstrap.php b/central_license_manager/bootstrap.php new file mode 100644 index 0000000..5252ecc --- /dev/null +++ b/central_license_manager/bootstrap.php @@ -0,0 +1,652 @@ + $prefix . 'apps', + 'licenses' => $prefix . 'licenses', + 'activations' => $prefix . 'activations', + ]; +} + +function clm_table(string $name): string +{ + $tables = clm_tables(); + return $tables[$name] ?? clm_safe_identifier((clm_cfg('table_prefix') ?: 'clm_') . $name); +} + +function clm_html($value): string +{ + return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); +} + +function clm_base_url(): string +{ + if (PHP_SAPI === 'cli') { + return ''; + } + + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $dir = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/central_license_manager/index.php')), '/'); + + if ($dir === '.' || $dir == '/') { + $dir = ''; + } + + return $scheme . '://' . $host . $dir; +} + +function clm_using_parent_db(): bool +{ + $config = clm_cfg(); + + return $config['db_host'] === '' + && $config['db_name'] === '' + && $config['db_user'] === '' + && file_exists(__DIR__ . '/../db/config.php'); +} + +function clm_db(): PDO +{ + static $pdo = null; + + if ($pdo instanceof PDO) { + return $pdo; + } + + $config = clm_cfg(); + + if (clm_using_parent_db()) { + require_once __DIR__ . '/../db/config.php'; + $pdo = db(); + return $pdo; + } + + if ($config['db_host'] === '' || $config['db_name'] === '' || $config['db_user'] === '') { + throw new RuntimeException('Central License Manager DB is not configured. Set CLM_DB_HOST, CLM_DB_NAME, CLM_DB_USER and CLM_DB_PASS in config.php or the environment before moving this folder to another server.'); + } + + $dsn = 'mysql:host=' . $config['db_host'] . ';dbname=' . $config['db_name'] . ';charset=' . ($config['db_charset'] ?: 'utf8mb4'); + $pdo = new PDO($dsn, $config['db_user'], $config['db_pass'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + + return $pdo; +} + +function clm_column_exists(string $table, string $column): bool +{ + $table = clm_safe_identifier($table); + $column = clm_safe_identifier($column); + $stmt = clm_db()->prepare("SHOW COLUMNS FROM `{$table}` LIKE ?"); + $stmt->execute([$column]); + return (bool)$stmt->fetch(); +} + +function clm_ensure_schema(): void +{ + static $ready = false; + + if ($ready) { + return; + } + + $ready = true; + $pdo = clm_db(); + $tables = clm_tables(); + $apps = $tables['apps']; + $licenses = $tables['licenses']; + $activations = $tables['activations']; + + $pdo->exec("CREATE TABLE IF NOT EXISTS `{$apps}` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `slug` VARCHAR(120) NOT NULL UNIQUE, + `name` VARCHAR(190) NOT NULL, + `status` ENUM('active', 'inactive') NOT NULL DEFAULT 'active', + `notes` TEXT DEFAULT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $pdo->exec("CREATE TABLE IF NOT EXISTS `{$licenses}` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `app_id` INT NOT NULL, + `license_key` VARCHAR(255) NOT NULL UNIQUE, + `max_activations` INT NOT NULL DEFAULT 1, + `max_counters` INT NOT NULL DEFAULT 1, + `status` ENUM('active', 'suspended', 'expired') NOT NULL DEFAULT 'active', + `owner` VARCHAR(255) DEFAULT NULL, + `address` TEXT DEFAULT NULL, + `customer_name` VARCHAR(255) DEFAULT NULL, + `customer_email` VARCHAR(255) DEFAULT NULL, + `notes` TEXT DEFAULT NULL, + `expires_at` DATETIME DEFAULT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_clm_app_id` (`app_id`), + FOREIGN KEY (`app_id`) REFERENCES `{$apps}`(`id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $pdo->exec("CREATE TABLE IF NOT EXISTS `{$activations}` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `license_id` INT NOT NULL, + `fingerprint` VARCHAR(255) NOT NULL, + `domain` VARCHAR(255) DEFAULT NULL, + `product` VARCHAR(255) DEFAULT NULL, + `product_version` VARCHAR(120) DEFAULT NULL, + `app_slug` VARCHAR(120) DEFAULT NULL, + `status` ENUM('active', 'deactivated') NOT NULL DEFAULT 'active', + `activation_token` VARCHAR(255) DEFAULT NULL, + `activated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `last_seen_at` DATETIME DEFAULT NULL, + `deactivated_at` DATETIME DEFAULT NULL, + UNIQUE KEY `uniq_clm_license_machine` (`license_id`, `fingerprint`), + INDEX `idx_clm_activation_status` (`status`), + INDEX `idx_clm_activation_domain` (`domain`), + FOREIGN KEY (`license_id`) REFERENCES `{$licenses}`(`id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $licenseAlterMap = [ + 'customer_name' => "ALTER TABLE `{$licenses}` ADD COLUMN `customer_name` VARCHAR(255) DEFAULT NULL AFTER `address`", + 'customer_email' => "ALTER TABLE `{$licenses}` ADD COLUMN `customer_email` VARCHAR(255) DEFAULT NULL AFTER `customer_name`", + 'notes' => "ALTER TABLE `{$licenses}` ADD COLUMN `notes` TEXT DEFAULT NULL AFTER `customer_email`", + 'expires_at' => "ALTER TABLE `{$licenses}` ADD COLUMN `expires_at` DATETIME DEFAULT NULL AFTER `notes`", + 'max_counters' => "ALTER TABLE `{$licenses}` ADD COLUMN `max_counters` INT NOT NULL DEFAULT 1 AFTER `max_activations`", + ]; + + foreach ($licenseAlterMap as $column => $sql) { + if (!clm_column_exists($licenses, $column)) { + $pdo->exec($sql); + } + } + + $activationAlterMap = [ + 'product_version' => "ALTER TABLE `{$activations}` ADD COLUMN `product_version` VARCHAR(120) DEFAULT NULL AFTER `product`", + 'app_slug' => "ALTER TABLE `{$activations}` ADD COLUMN `app_slug` VARCHAR(120) DEFAULT NULL AFTER `product_version`", + 'status' => "ALTER TABLE `{$activations}` ADD COLUMN `status` ENUM('active', 'deactivated') NOT NULL DEFAULT 'active' AFTER `app_slug`", + 'activation_token' => "ALTER TABLE `{$activations}` ADD COLUMN `activation_token` VARCHAR(255) DEFAULT NULL AFTER `status`", + 'last_seen_at' => "ALTER TABLE `{$activations}` ADD COLUMN `last_seen_at` DATETIME DEFAULT NULL AFTER `activated_at`", + 'deactivated_at' => "ALTER TABLE `{$activations}` ADD COLUMN `deactivated_at` DATETIME DEFAULT NULL AFTER `last_seen_at`", + ]; + + foreach ($activationAlterMap as $column => $sql) { + if (!clm_column_exists($activations, $column)) { + $pdo->exec($sql); + } + } + + clm_resolve_app((string)clm_cfg('default_app_slug'), (string)clm_cfg('default_app_name'), true); +} + +function clm_slugify($value): string +{ + $value = strtolower(trim((string)$value)); + $value = preg_replace('/[^a-z0-9]+/', '-', $value); + $value = trim((string)$value, '-'); + + if ($value === '') { + $value = strtolower((string)(clm_cfg('default_app_slug') ?: 'legacy')); + } + + return $value; +} + +function clm_secret_valid($secret): bool +{ + return hash_equals((string)clm_cfg('api_secret'), (string)$secret); +} + +function clm_resolve_app(string $slug = '', string $name = '', bool $createIfMissing = false): ?array +{ + clm_ensure_schema(); + + $tables = clm_tables(); + $apps = $tables['apps']; + $slug = clm_slugify($slug !== '' ? $slug : ($name !== '' ? $name : (string)clm_cfg('default_app_slug'))); + $name = trim($name) !== '' ? trim($name) : ucwords(str_replace('-', ' ', $slug)); + + $stmt = clm_db()->prepare("SELECT * FROM `{$apps}` WHERE slug = ? LIMIT 1"); + $stmt->execute([$slug]); + $app = $stmt->fetch(); + + if ($app) { + return $app; + } + + if (!$createIfMissing) { + return null; + } + + $stmt = clm_db()->prepare("INSERT INTO `{$apps}` (slug, name, status) VALUES (?, ?, 'active')"); + $stmt->execute([$slug, $name]); + + $stmt = clm_db()->prepare("SELECT * FROM `{$apps}` WHERE slug = ? LIMIT 1"); + $stmt->execute([$slug]); + return $stmt->fetch() ?: null; +} + +function clm_app_by_id(int $id): ?array +{ + clm_ensure_schema(); + + $apps = clm_table('apps'); + $stmt = clm_db()->prepare("SELECT * FROM `{$apps}` WHERE id = ? LIMIT 1"); + $stmt->execute([$id]); + $row = $stmt->fetch(); + + return $row ?: null; +} + +function clm_update_app_record(array $app, array $data): array +{ + clm_ensure_schema(); + + $apps = clm_table('apps'); + $fields = []; + $params = []; + + if (array_key_exists('name', $data) || array_key_exists('slug', $data)) { + $name = trim((string)($data['name'] ?? (string)$app['name'])); + $slugInput = trim((string)($data['slug'] ?? (string)$app['slug'])); + + if ($name === '' && $slugInput === '') { + throw new RuntimeException('App name or slug is required.'); + } + + $slug = clm_slugify($slugInput !== '' ? $slugInput : $name); + $name = $name !== '' ? $name : ucwords(str_replace('-', ' ', $slug)); + + $check = clm_db()->prepare("SELECT id FROM `{$apps}` WHERE slug = ? AND id <> ? LIMIT 1"); + $check->execute([$slug, (int)$app['id']]); + if ($check->fetch()) { + throw new RuntimeException('Another app already uses this slug.'); + } + + $fields[] = '`name` = ?'; + $params[] = $name; + $fields[] = '`slug` = ?'; + $params[] = $slug; + } + + if (isset($data['status'])) { + $status = strtolower(trim((string)$data['status'])); + if (in_array($status, ['active', 'inactive'], true)) { + $fields[] = '`status` = ?'; + $params[] = $status; + } + } + + if (empty($fields)) { + return clm_app_by_id((int)$app['id']) ?: $app; + } + + $params[] = (int)$app['id']; + $stmt = clm_db()->prepare("UPDATE `{$apps}` SET " . implode(', ', $fields) . " WHERE id = ?"); + $stmt->execute($params); + + return clm_app_by_id((int)$app['id']) ?: $app; +} + +function clm_license_with_app_by_key(string $licenseKey): ?array +{ + clm_ensure_schema(); + + $tables = clm_tables(); + $licenses = $tables['licenses']; + $apps = $tables['apps']; + + $stmt = clm_db()->prepare("SELECT l.*, a.slug AS app_slug, a.name AS app_name + FROM `{$licenses}` l + INNER JOIN `{$apps}` a ON a.id = l.app_id + WHERE l.license_key = ? + LIMIT 1"); + $stmt->execute([strtoupper(trim($licenseKey))]); + $row = $stmt->fetch(); + + return $row ?: null; +} + +function clm_license_with_app_by_id(int $id): ?array +{ + clm_ensure_schema(); + + $tables = clm_tables(); + $licenses = $tables['licenses']; + $apps = $tables['apps']; + + $stmt = clm_db()->prepare("SELECT l.*, a.slug AS app_slug, a.name AS app_name + FROM `{$licenses}` l + INNER JOIN `{$apps}` a ON a.id = l.app_id + WHERE l.id = ? + LIMIT 1"); + $stmt->execute([$id]); + $row = $stmt->fetch(); + + return $row ?: null; +} + +function clm_generate_license_key(string $prefix = 'FLAT'): string +{ + $prefix = preg_replace('/[^A-Z0-9]/', '', strtoupper(trim($prefix))) ?: 'FLAT'; + + do { + $key = $prefix + . '-' . strtoupper(bin2hex(random_bytes(2))) + . '-' . strtoupper(bin2hex(random_bytes(2))) + . '-' . strtoupper(bin2hex(random_bytes(2))); + } while (clm_license_with_app_by_key($key)); + + return $key; +} + +function clm_make_token(string $licenseKey, string $fingerprint): string +{ + return hash_hmac('sha256', strtoupper(trim($licenseKey)) . $fingerprint, (string)clm_cfg('api_secret')); +} + +function clm_active_activation_count(int $licenseId): int +{ + clm_ensure_schema(); + + $activations = clm_table('activations'); + $stmt = clm_db()->prepare("SELECT COUNT(*) FROM `{$activations}` WHERE license_id = ? AND status = 'active'"); + $stmt->execute([$licenseId]); + + return (int)$stmt->fetchColumn(); +} + +function clm_license_is_expired(array $license): bool +{ + if (empty($license['expires_at'])) { + return false; + } + + $expiresAt = strtotime((string)$license['expires_at']); + return $expiresAt !== false && $expiresAt < time(); +} + +function clm_upsert_activation(array $license, string $fingerprint, string $domain = '', string $product = '', string $productVersion = ''): array +{ + clm_ensure_schema(); + + $activations = clm_table('activations'); + $pdo = clm_db(); + $token = clm_make_token((string)$license['license_key'], $fingerprint); + $product = trim($product) !== '' ? trim($product) : (string)$license['app_name']; + + $stmt = $pdo->prepare("SELECT * FROM `{$activations}` WHERE license_id = ? AND fingerprint = ? LIMIT 1"); + $stmt->execute([(int)$license['id'], $fingerprint]); + $existing = $stmt->fetch(); + $currentActive = clm_active_activation_count((int)$license['id']); + + if ($existing) { + if (($existing['status'] ?? 'active') !== 'active' && $currentActive >= (int)$license['max_activations']) { + return [ + 'success' => false, + 'error' => 'Maximum activation limit reached.' + ]; + } + + $stmt = $pdo->prepare("UPDATE `{$activations}` + SET domain = ?, + product = ?, + product_version = ?, + app_slug = ?, + status = 'active', + activation_token = ?, + last_seen_at = NOW(), + deactivated_at = NULL + WHERE id = ?"); + $stmt->execute([ + $domain !== '' ? $domain : null, + $product, + $productVersion !== '' ? $productVersion : null, + (string)$license['app_slug'], + $token, + (int)$existing['id'], + ]); + } else { + if ($currentActive >= (int)$license['max_activations']) { + return [ + 'success' => false, + 'error' => 'Maximum activation limit reached.' + ]; + } + + $stmt = $pdo->prepare("INSERT INTO `{$activations}` + (license_id, fingerprint, domain, product, product_version, app_slug, status, activation_token, last_seen_at) + VALUES (?, ?, ?, ?, ?, ?, 'active', ?, NOW())"); + $stmt->execute([ + (int)$license['id'], + $fingerprint, + $domain !== '' ? $domain : null, + $product, + $productVersion !== '' ? $productVersion : null, + (string)$license['app_slug'], + $token, + ]); + } + + return [ + 'success' => true, + 'activation_token' => $token, + ]; +} + +function clm_request_data(): array +{ + $data = []; + $raw = file_get_contents('php://input'); + + if (is_string($raw) && trim($raw) !== '') { + $decoded = json_decode($raw, true); + if (is_array($decoded)) { + $data = $decoded; + } + } + + if (!empty($_POST)) { + $data = array_merge($data, $_POST); + } + + if (!empty($_GET)) { + $data = array_merge($data, $_GET); + } + + return $data; +} + +function clm_json(array $data, int $status = 200): void +{ + if (!headers_sent()) { + http_response_code($status); + header('Content-Type: application/json; charset=UTF-8'); + } + + echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + exit; +} + +function clm_issue_license_record(array $data): array +{ + clm_ensure_schema(); + + $tables = clm_tables(); + $licenses = $tables['licenses']; + $app = clm_resolve_app((string)($data['app_slug'] ?? ''), (string)($data['app_name'] ?? ''), true); + + if (!$app) { + throw new RuntimeException('Unable to resolve app.'); + } + + $licenseKey = strtoupper(trim((string)($data['license_key'] ?? ''))); + if ($licenseKey === '') { + $licenseKey = clm_generate_license_key((string)($data['prefix'] ?? 'FLAT')); + } + + $maxActivations = max(1, (int)($data['max_activations'] ?? 1)); + $maxCounters = max(1, (int)($data['max_counters'] ?? 1)); + $owner = trim((string)($data['owner'] ?? '')); + $address = trim((string)($data['address'] ?? '')); + $customerName = trim((string)($data['customer_name'] ?? $owner)); + $customerEmail = trim((string)($data['customer_email'] ?? '')); + $notes = trim((string)($data['notes'] ?? '')); + $expiresAt = trim((string)($data['expires_at'] ?? '')); + + $stmt = clm_db()->prepare("INSERT INTO `{$licenses}` + (app_id, license_key, max_activations, max_counters, status, owner, address, customer_name, customer_email, notes, expires_at) + VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)"); + $stmt->execute([ + (int)$app['id'], + $licenseKey, + $maxActivations, + $maxCounters, + $owner !== '' ? $owner : null, + $address !== '' ? $address : null, + $customerName !== '' ? $customerName : null, + $customerEmail !== '' ? $customerEmail : null, + $notes !== '' ? $notes : null, + $expiresAt !== '' ? $expiresAt : null, + ]); + + $license = clm_license_with_app_by_key($licenseKey); + + if (!$license) { + throw new RuntimeException('License was created but could not be reloaded.'); + } + + return $license; +} + +function clm_update_license_record(array $license, array $data): array +{ + clm_ensure_schema(); + + $licenses = clm_table('licenses'); + $fields = []; + $params = []; + + if (isset($data['status'])) { + $status = strtolower(trim((string)$data['status'])); + if (in_array($status, ['active', 'suspended', 'expired'], true)) { + $fields[] = '`status` = ?'; + $params[] = $status; + } + } + + if (isset($data['max_activations']) && (int)$data['max_activations'] > 0) { + $fields[] = '`max_activations` = ?'; + $params[] = max(1, (int)$data['max_activations']); + } + + if (isset($data['max_counters']) && (int)$data['max_counters'] > 0) { + $fields[] = '`max_counters` = ?'; + $params[] = max(1, (int)$data['max_counters']); + } + + if (array_key_exists('owner', $data)) { + $fields[] = '`owner` = ?'; + $owner = trim((string)$data['owner']); + $params[] = $owner !== '' ? $owner : null; + } + + if (array_key_exists('address', $data)) { + $fields[] = '`address` = ?'; + $address = trim((string)$data['address']); + $params[] = $address !== '' ? $address : null; + } + + if (array_key_exists('customer_name', $data)) { + $fields[] = '`customer_name` = ?'; + $customerName = trim((string)$data['customer_name']); + $params[] = $customerName !== '' ? $customerName : null; + } + + if (array_key_exists('customer_email', $data)) { + $fields[] = '`customer_email` = ?'; + $customerEmail = trim((string)$data['customer_email']); + $params[] = $customerEmail !== '' ? $customerEmail : null; + } + + if (array_key_exists('notes', $data)) { + $fields[] = '`notes` = ?'; + $notes = trim((string)$data['notes']); + $params[] = $notes !== '' ? $notes : null; + } + + if (array_key_exists('expires_at', $data)) { + $fields[] = '`expires_at` = ?'; + $expiresAt = trim((string)$data['expires_at']); + $params[] = $expiresAt !== '' ? $expiresAt : null; + } + + if (isset($data['app_slug']) || isset($data['app_name'])) { + $app = clm_resolve_app((string)($data['app_slug'] ?? ''), (string)($data['app_name'] ?? ''), true); + if ($app) { + $fields[] = '`app_id` = ?'; + $params[] = (int)$app['id']; + } + } + + if (empty($fields)) { + return clm_license_with_app_by_id((int)$license['id']) ?: $license; + } + + $params[] = (int)$license['id']; + $sql = "UPDATE `{$licenses}` SET " . implode(', ', $fields) . " WHERE id = ?"; + $stmt = clm_db()->prepare($sql); + $stmt->execute($params); + + return clm_license_with_app_by_id((int)$license['id']) ?: $license; +} + +function clm_set_flash(string $type, string $message): void +{ + $_SESSION['clm_flash'] = [ + 'type' => $type, + 'message' => $message, + ]; +} + +function clm_get_flash(): ?array +{ + if (empty($_SESSION['clm_flash'])) { + return null; + } + + $flash = $_SESSION['clm_flash']; + unset($_SESSION['clm_flash']); + + return is_array($flash) ? $flash : null; +} + +function clm_redirect(string $location): void +{ + header('Location: ' . $location); + exit; +} + +function clm_manager_mode_label(): string +{ + return clm_using_parent_db() ? 'Using current app database' : 'Using standalone CLM database settings'; +} diff --git a/central_license_manager/config.php b/central_license_manager/config.php new file mode 100644 index 0000000..fbe20d2 --- /dev/null +++ b/central_license_manager/config.php @@ -0,0 +1,33 @@ + getenv('CLM_MANAGER_NAME') ?: 'Central License Manager', + 'table_prefix' => preg_replace('/[^a-zA-Z0-9_]/', '', getenv('CLM_TABLE_PREFIX') ?: 'clm_') ?: 'clm_', + 'api_secret' => getenv('CLM_API_SECRET') ?: '1485-5215-2578', + 'admin_password' => getenv('CLM_ADMIN_PASSWORD') ?: 'Meezan@2026', + 'default_app_slug' => getenv('CLM_DEFAULT_APP_SLUG') ?: 'legacy', + 'default_app_name' => getenv('CLM_DEFAULT_APP_NAME') ?: 'Legacy App', + 'db_host' => getenv('CLM_DB_HOST') ?: '', + 'db_name' => getenv('CLM_DB_NAME') ?: '', + 'db_user' => getenv('CLM_DB_USER') ?: '', + 'db_pass' => getenv('CLM_DB_PASS') ?: '', + 'db_charset' => getenv('CLM_DB_CHARSET') ?: 'utf8mb4', + ]; + + return $config; +} diff --git a/central_license_manager/index.php b/central_license_manager/index.php new file mode 100644 index 0000000..7072699 --- /dev/null +++ b/central_license_manager/index.php @@ -0,0 +1,473 @@ +getMessage(); +} + +$action = strtolower(trim((string)($_GET['action'] ?? ''))); +$input = clm_request_data(); + +if ($action !== '') { + if ($schemaError !== '') { + clm_json(['success' => false, 'error' => $schemaError], 500); + } + + try { + $tables = clm_tables(); + $licensesTable = $tables['licenses']; + $activationsTable = $tables['activations']; + $appsTable = $tables['apps']; + + switch ($action) { + case 'health': + clm_json([ + 'success' => true, + 'manager' => clm_cfg('manager_name'), + 'mode' => clm_manager_mode_label(), + 'base_url' => clm_base_url(), + 'tables' => $tables, + ]); + break; + + case 'activate': + $key = strtoupper(trim((string)($input['license_key'] ?? ''))); + $fingerprint = trim((string)($input['fingerprint'] ?? '')); + $domain = trim((string)($input['domain'] ?? '')); + $product = trim((string)($input['product'] ?? '')); + $productVersion = trim((string)($input['product_version'] ?? '')); + $requestedAppSlug = trim((string)($input['app_slug'] ?? '')); + + if ($key === '' || $fingerprint === '') { + clm_json(['success' => false, 'error' => 'Missing required parameters.'], 422); + } + + $license = clm_license_with_app_by_key($key); + if (!$license) { + clm_json(['success' => false, 'error' => 'Invalid license key.'], 404); + } + + if ($requestedAppSlug !== '' && clm_slugify($requestedAppSlug) !== (string)$license['app_slug']) { + clm_json(['success' => false, 'error' => 'License does not belong to this app.'], 409); + } + + if (($license['status'] ?? 'suspended') !== 'active') { + clm_json(['success' => false, 'error' => 'License is ' . $license['status'] . '.'], 409); + } + + if (clm_license_is_expired($license)) { + clm_json(['success' => false, 'error' => 'License is expired.'], 409); + } + + $activation = clm_upsert_activation($license, $fingerprint, $domain, $product, $productVersion); + if (empty($activation['success'])) { + clm_json($activation, 409); + } + + clm_json([ + 'success' => true, + 'activation_token' => $activation['activation_token'], + 'max_activations' => (int)$license['max_activations'], + 'allowed_activations' => (int)$license['max_activations'], + 'max_counters' => (int)$license['max_counters'], + 'current_activations' => clm_active_activation_count((int)$license['id']), + 'app_slug' => (string)$license['app_slug'], + 'app_name' => (string)$license['app_name'], + ]); + break; + + case 'verify': + $key = strtoupper(trim((string)($input['license_key'] ?? ''))); + $fingerprint = trim((string)($input['fingerprint'] ?? '')); + $token = trim((string)($input['token'] ?? '')); + $requestedAppSlug = trim((string)($input['app_slug'] ?? '')); + + if ($key === '' || $fingerprint === '' || $token === '') { + clm_json(['success' => false, 'error' => 'Missing required parameters.'], 422); + } + + $license = clm_license_with_app_by_key($key); + if (!$license) { + clm_json(['success' => false, 'error' => 'Invalid license key.'], 404); + } + + if ($requestedAppSlug !== '' && clm_slugify($requestedAppSlug) !== (string)$license['app_slug']) { + clm_json(['success' => false, 'error' => 'License does not belong to this app.'], 409); + } + + if (!hash_equals(clm_make_token($key, $fingerprint), $token)) { + clm_json(['success' => false, 'error' => 'Invalid activation token.'], 403); + } + + if (($license['status'] ?? 'suspended') !== 'active' || clm_license_is_expired($license)) { + clm_json(['success' => false, 'error' => 'License is no longer active.'], 409); + } + + $stmt = clm_db()->prepare("SELECT * FROM `{$activationsTable}` WHERE license_id = ? AND fingerprint = ? AND status = 'active' LIMIT 1"); + $stmt->execute([(int)$license['id'], $fingerprint]); + $activation = $stmt->fetch(); + + if (!$activation) { + clm_json(['success' => false, 'error' => 'Activation not found.'], 404); + } + + $stmt = clm_db()->prepare("UPDATE `{$activationsTable}` SET last_seen_at = NOW() WHERE id = ?"); + $stmt->execute([(int)$activation['id']]); + + clm_json([ + 'success' => true, + 'max_activations' => (int)$license['max_activations'], + 'allowed_activations' => (int)$license['max_activations'], + 'max_counters' => (int)$license['max_counters'], + 'current_activations' => clm_active_activation_count((int)$license['id']), + 'app_slug' => (string)$license['app_slug'], + 'app_name' => (string)$license['app_name'], + ]); + break; + + case 'deactivate': + $key = strtoupper(trim((string)($input['license_key'] ?? ''))); + $fingerprint = trim((string)($input['fingerprint'] ?? '')); + + if ($key === '' || $fingerprint === '') { + clm_json(['success' => false, 'error' => 'Missing required parameters.'], 422); + } + + $license = clm_license_with_app_by_key($key); + if (!$license) { + clm_json(['success' => false, 'error' => 'Invalid license key.'], 404); + } + + $stmt = clm_db()->prepare("SELECT * FROM `{$activationsTable}` WHERE license_id = ? AND fingerprint = ? LIMIT 1"); + $stmt->execute([(int)$license['id'], $fingerprint]); + $activation = $stmt->fetch(); + + if (!$activation) { + clm_json(['success' => false, 'error' => 'Activation not found.'], 404); + } + + if (($activation['status'] ?? 'active') !== 'active') { + clm_json(['success' => true, 'already_inactive' => true]); + } + + $stmt = clm_db()->prepare("UPDATE `{$activationsTable}` SET status = 'deactivated', deactivated_at = NOW(), last_seen_at = NOW() WHERE id = ?"); + $stmt->execute([(int)$activation['id']]); + + clm_json(['success' => true]); + break; + + case 'issue': + if (!clm_secret_valid($input['secret'] ?? '')) { + clm_json(['success' => false, 'error' => 'Unauthorized.'], 403); + } + + $license = clm_issue_license_record($input); + clm_json([ + 'success' => true, + 'license_key' => $license['license_key'], + 'app_slug' => $license['app_slug'], + 'app_name' => $license['app_name'], + 'max_activations' => (int)$license['max_activations'], + 'allowed_activations' => (int)$license['max_activations'], + 'max_counters' => (int)$license['max_counters'], + 'owner' => $license['owner'], + 'address' => $license['address'], + 'customer_name' => $license['customer_name'], + 'customer_email' => $license['customer_email'], + 'expires_at' => $license['expires_at'], + ]); + break; + + case 'list': + if (!clm_secret_valid($input['secret'] ?? '')) { + clm_json(['success' => false, 'error' => 'Unauthorized.'], 403); + } + + $stmt = clm_db()->query("SELECT l.*, a.slug AS app_slug, a.name AS app_name, + (SELECT COUNT(*) FROM `{$activationsTable}` act WHERE act.license_id = l.id AND act.status = 'active') AS current_activations + FROM `{$licensesTable}` l + INNER JOIN `{$appsTable}` a ON a.id = l.app_id + ORDER BY l.created_at DESC"); + + clm_json([ + 'success' => true, + 'data' => $stmt->fetchAll(), + ]); + break; + + case 'apps': + if (!clm_secret_valid($input['secret'] ?? '')) { + clm_json(['success' => false, 'error' => 'Unauthorized.'], 403); + } + + $stmt = clm_db()->query("SELECT a.*, + (SELECT COUNT(*) FROM `{$licensesTable}` l WHERE l.app_id = a.id) AS licenses_count, + (SELECT COUNT(*) + FROM `{$activationsTable}` act + INNER JOIN `{$licensesTable}` l2 ON l2.id = act.license_id + WHERE l2.app_id = a.id AND act.status = 'active') AS active_activations + FROM `{$appsTable}` a + ORDER BY a.name ASC"); + + clm_json([ + 'success' => true, + 'data' => $stmt->fetchAll(), + ]); + break; + + case 'update': + if (!clm_secret_valid($input['secret'] ?? '')) { + clm_json(['success' => false, 'error' => 'Unauthorized.'], 403); + } + + $license = null; + if (!empty($input['id'])) { + $license = clm_license_with_app_by_id((int)$input['id']); + } elseif (!empty($input['license_key'])) { + $license = clm_license_with_app_by_key((string)$input['license_key']); + } + + if (!$license) { + clm_json(['success' => false, 'error' => 'License not found.'], 404); + } + + $updated = clm_update_license_record($license, $input); + clm_json([ + 'success' => true, + 'data' => $updated, + ]); + break; + + default: + clm_json(['success' => false, 'error' => 'Invalid endpoint.'], 404); + } + } catch (Throwable $e) { + clm_json(['success' => false, 'error' => $e->getMessage()], 500); + } +} + +$stats = [ + 'apps' => 0, + 'licenses' => 0, + 'activations' => 0, +]; + +if ($schemaError === '') { + try { + $tables = clm_tables(); + $stats['apps'] = (int)clm_db()->query("SELECT COUNT(*) FROM `{$tables['apps']}`")->fetchColumn(); + $stats['licenses'] = (int)clm_db()->query("SELECT COUNT(*) FROM `{$tables['licenses']}`")->fetchColumn(); + $stats['activations'] = (int)clm_db()->query("SELECT COUNT(*) FROM `{$tables['activations']}` WHERE status = 'active'")->fetchColumn(); + } catch (Throwable $e) { + $schemaError = $e->getMessage(); + } +} + +$baseUrl = clm_base_url(); +?> + + +
+ + +This folder is isolated from the legacy license_manager/ code, auto-creates its own multi-app tables, and can be copied to another server later without rewriting the rest of your app first.
clm_apps, clm_licenses, and clm_activations tables.allowed_activations.LICENSE_API_URL.index.php?action=healthindex.php?action=activateindex.php?action=verifyindex.php?action=deactivateindex.php?action=issue / list / update+ = !empty($result['success']) ? 'Schema ready' : 'Schema check failed' ?> +
+= clm_html($result['message']) ?>
+= clm_html(clm_manager_mode_label()) ?>
+ LICENSE_API_URL== clm_html($baseUrl) ?> += clm_html($tables['apps']) ?>= clm_html($tables['licenses']) ?>= clm_html($tables['activations']) ?>central_license_manager/ folder to the new host.CLM_DB_*, CLM_API_SECRET, and CLM_ADMIN_PASSWORD there.LICENSE_API_URL.Use the configured admin password to manage apps, licenses, and activations from one place.
+Mode: = clm_html(clm_manager_mode_label()) ?>
+
+ Manage licenses and installations for multiple products from one isolated module. This dashboard is intentionally separate from the old in-app license manager so you can move it later.
+ +Each app should call this module, not keep its own separate activation database.
+ = clm_html($baseUrl) ?> +Current API stays backward compatible with older clients by returning both max_activations and allowed_activations.
| Name | +Slug | +Status | +Licenses | +Active installs | +Action | +
|---|---|---|---|---|---|
| No apps yet. | |||||
| + + + | ++ + + | ++ + | += (int)$app['licenses_count'] ?> | += (int)$app['active_activations'] ?> | ++ + | +
No licenses yet.
= clm_html($license['license_key']) ?>
+
+ | License | +App | +Domain | +Fingerprint | +Product | +Status | +Activated | +Last seen | +Action | +
|---|---|---|---|---|---|---|---|---|
| No activations yet. | ||||||||
= clm_html($activation['license_key']) ?> |
+ = clm_html($activation['app_name']) ?> | += clm_html($activation['domain']) ?> | += clm_html(substr((string)$activation['fingerprint'], 0, 16)) ?>… |
+ = clm_html($activation['product']) ?> | += clm_html($activation['status']) ?> | += clm_html((string)$activation['activated_at']) ?> | += clm_html((string)($activation['last_seen_at'] ?: '—')) ?> | ++ + + + + + | +
= htmlspecialchars((string)$licenseIdentity['app_name']) ?>
+ ·
+ = htmlspecialchars((string)$licenseIdentity['app_slug']) ?>
+