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(); +?> + + + + + + Central License Manager | Multi-App Activation Hub + + + + + +
+
+
+ Standalone module +

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.

+
+
+ + Apps +
+
+ + Licenses +
+
+ + Active installs +
+
+ +
+ +
+ +
+
+

What this module adds

+
    +
  • Dedicated clm_apps, clm_licenses, and clm_activations tables.
  • +
  • Backward-compatible API responses for existing clients expecting allowed_activations.
  • +
  • Move-friendly structure so you can copy this folder to a separate host later.
  • +
+
+
+

Quick start

+
    +
  1. Open install.php once.
  2. +
  3. Open manage.php and create your app records.
  4. +
  5. Point each client app to this folder through LICENSE_API_URL.
  6. +
+
+
+

Available endpoints

+
    +
  • index.php?action=health
  • +
  • index.php?action=activate
  • +
  • index.php?action=verify
  • +
  • index.php?action=deactivate
  • +
  • index.php?action=issue / list / update
  • +
+
+
+
+ + diff --git a/central_license_manager/install.php b/central_license_manager/install.php new file mode 100644 index 0000000..9f5c0fc --- /dev/null +++ b/central_license_manager/install.php @@ -0,0 +1,147 @@ + false, + 'message' => 'Schema not checked yet.', +]; + +try { + clm_ensure_schema(); + $result = [ + 'success' => true, + 'message' => 'Central License Manager schema is ready.', + ]; +} catch (Throwable $e) { + $result = [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + $statusCode = 500; +} + +if (PHP_SAPI === 'cli') { + echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . PHP_EOL; + exit($result['success'] ? 0 : 1); +} + +if (isset($_GET['json'])) { + clm_json($result, $statusCode); +} + +$tables = clm_tables(); +$baseUrl = clm_base_url(); +?> + + + + + + Install Central License Manager + + + + + +
+
+

Central License Manager installer

+

+ +

+

+
+ Open manager + Back to landing +
+
+ +
+

Current configuration mode

+

+ LICENSE_API_URL= +
+ +
+

Managed tables

+ +
+ +
+

Move this folder later

+ +
+
+ + diff --git a/central_license_manager/manage.php b/central_license_manager/manage.php new file mode 100644 index 0000000..da28d20 --- /dev/null +++ b/central_license_manager/manage.php @@ -0,0 +1,797 @@ +getMessage(); +} + +if (isset($_GET['logout'])) { + unset($_SESSION['clm_admin_auth']); + clm_redirect('manage.php'); +} + +$error = $schemaError; +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'login') { + if ($schemaError !== '') { + $error = $schemaError; + } elseif (hash_equals((string)clm_cfg('admin_password'), (string)($_POST['password'] ?? ''))) { + $_SESSION['clm_admin_auth'] = true; + clm_redirect('manage.php'); + } else { + $error = 'Wrong admin password.'; + } +} + +$isAuthenticated = !empty($_SESSION['clm_admin_auth']); + +if ($isAuthenticated && $schemaError === '' && $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') !== 'login') { + try { + $action = (string)($_POST['action'] ?? ''); + $tables = clm_tables(); + $activationsTable = $tables['activations']; + + switch ($action) { + case 'create_app': + $name = trim((string)($_POST['name'] ?? '')); + $slug = trim((string)($_POST['slug'] ?? '')); + if ($name === '' && $slug === '') { + throw new RuntimeException('App name is required.'); + } + $app = clm_resolve_app($slug, $name, true); + if (!$app) { + throw new RuntimeException('App could not be created.'); + } + clm_set_flash('success', 'App saved: ' . $app['name']); + break; + + case 'save_app': + $app = clm_app_by_id((int)($_POST['id'] ?? 0)); + if (!$app) { + throw new RuntimeException('App not found.'); + } + $app = clm_update_app_record($app, [ + 'name' => $_POST['name'] ?? '', + 'slug' => $_POST['slug'] ?? '', + 'status' => $_POST['status'] ?? 'active', + ]); + clm_set_flash('success', 'App updated: ' . $app['name']); + break; + + case 'issue_license': + $license = clm_issue_license_record([ + 'app_slug' => $_POST['app_slug'] ?? '', + 'app_name' => $_POST['app_name'] ?? '', + 'prefix' => $_POST['prefix'] ?? 'FLAT', + 'max_activations' => $_POST['max_activations'] ?? 1, + 'max_counters' => $_POST['max_counters'] ?? 1, + 'owner' => $_POST['owner'] ?? '', + 'customer_name' => $_POST['customer_name'] ?? '', + 'customer_email' => $_POST['customer_email'] ?? '', + 'address' => $_POST['address'] ?? '', + 'expires_at' => $_POST['expires_at'] ?? '', + 'notes' => $_POST['notes'] ?? '', + ]); + clm_set_flash('success', 'License issued: ' . $license['license_key']); + break; + + case 'save_license': + $license = clm_license_with_app_by_id((int)($_POST['id'] ?? 0)); + if (!$license) { + throw new RuntimeException('License not found.'); + } + clm_update_license_record($license, [ + 'owner' => $_POST['owner'] ?? '', + 'customer_name' => $_POST['customer_name'] ?? '', + 'customer_email' => $_POST['customer_email'] ?? '', + 'app_slug' => $_POST['app_slug'] ?? '', + 'max_activations' => $_POST['max_activations'] ?? 1, + 'max_counters' => $_POST['max_counters'] ?? 1, + 'expires_at' => $_POST['expires_at'] ?? '', + 'status' => $_POST['status'] ?? 'active', + 'notes' => $_POST['notes'] ?? '', + ]); + clm_set_flash('success', 'License updated.'); + break; + + case 'toggle_license': + $license = clm_license_with_app_by_id((int)($_POST['id'] ?? 0)); + if (!$license) { + throw new RuntimeException('License not found.'); + } + $nextStatus = ($license['status'] ?? 'active') === 'active' ? 'suspended' : 'active'; + clm_update_license_record($license, ['status' => $nextStatus]); + clm_set_flash('success', 'License status changed to ' . $nextStatus . '.'); + break; + + case 'deactivate_activation': + $id = (int)($_POST['id'] ?? 0); + if ($id <= 0) { + throw new RuntimeException('Activation not found.'); + } + $stmt = clm_db()->prepare("UPDATE `{$activationsTable}` SET status = 'deactivated', deactivated_at = NOW(), last_seen_at = NOW() WHERE id = ?"); + $stmt->execute([$id]); + clm_set_flash('success', 'Activation deactivated.'); + break; + } + } catch (Throwable $e) { + clm_set_flash('error', $e->getMessage()); + } + + clm_redirect('manage.php'); +} + +$flash = clm_get_flash(); + +if (!$isAuthenticated): +?> + + + + + + Central License Manager Login + + + + + +
+ Admin access +

Central License Manager

+

Use the configured admin password to manage apps, licenses, and activations from one place.

+ Mode: + +
+ +
+ + + + +
+ Back to module home +
+ + + 0, + 'licenses' => 0, + 'activations' => 0, +]; +$apps = []; +$licenses = []; +$activations = []; + +try { + $pdo = clm_db(); + $stats['apps'] = (int)$pdo->query("SELECT COUNT(*) FROM `{$appsTable}`")->fetchColumn(); + $stats['licenses'] = (int)$pdo->query("SELECT COUNT(*) FROM `{$licensesTable}`")->fetchColumn(); + $stats['activations'] = (int)$pdo->query("SELECT COUNT(*) FROM `{$activationsTable}` WHERE status = 'active'")->fetchColumn(); + + $apps = $pdo->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")->fetchAll(); + + $licenses = $pdo->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 active_activations + FROM `{$licensesTable}` l + INNER JOIN `{$appsTable}` a ON a.id = l.app_id + ORDER BY l.created_at DESC + LIMIT 200")->fetchAll(); + + $activations = $pdo->query("SELECT act.*, l.license_key, a.slug AS app_slug, a.name AS app_name + FROM `{$activationsTable}` act + INNER JOIN `{$licensesTable}` l ON l.id = act.license_id + INNER JOIN `{$appsTable}` a ON a.id = l.app_id + ORDER BY COALESCE(act.last_seen_at, act.activated_at) DESC + LIMIT 200")->fetchAll(); +} catch (Throwable $e) { + $flash = ['type' => 'error', 'message' => $e->getMessage()]; +} + +$baseUrl = clm_base_url(); +?> + + + + + + Central License Manager Dashboard + + + + + +
+
+
+ Multi-app dashboard +

+

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.

+ +
+ +
+ + +
+ +
+ + +
+
+ + Apps +
+
+ + Licenses +
+
+ + Active installs +
+
+ + Manager date +
+
+ +
+
+

Create app

+
+ + + +

You can rename the app or adjust its slug later in the Apps table.

+ +
+
+ +
+

Issue license

+
+ +
+ + + + + + + + +
+ + +
+
+ +
+

What to point clients to

+

Each app should call this module, not keep its own separate activation database.

+ +

Current API stays backward compatible with older clients by returning both max_activations and allowed_activations.

+
+
+ +
+

Apps

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameSlugStatusLicensesActive installsAction
No apps yet.
+
+ + +
+ +
+ +
Update client settings too if you rename this slug.
+
+ + + +
+
+
+ +
+

Licenses

+
+ +

No licenses yet.

+ + +
+
+
+ +
·
+
+ +
+ +
+ + +
+ + + + + + + + + +
+
+
Created:
+
Active installs:
+
+
+ +
+
+ +
+ + + +
+
+ + +
+
+ +
+

Recent activations

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LicenseAppDomainFingerprintProductStatusActivatedLast seenAction
No activations yet.
+ +
+ + + +
+ + Inactive + +
+
+
+
+ + diff --git a/index.php b/index.php index 27131b1..bfda1cf 100644 --- a/index.php +++ b/index.php @@ -2902,6 +2902,10 @@ if (isset($_POST['add_hr_department'])) { if (isset($_POST['settings']) && is_array($_POST['settings'])) { $settings = $_POST['settings']; $settings['weight_barcode_mode'] = in_array(($settings['weight_barcode_mode'] ?? 'weight'), ['weight', 'price'], true) ? $settings['weight_barcode_mode'] : 'weight'; + $licenseAppName = trim((string)($settings['license_app_name'] ?? '')); + $settings['license_app_name'] = $licenseAppName !== '' ? substr($licenseAppName, 0, 190) : ''; + $licenseAppSlug = trim((string)($settings['license_app_slug'] ?? '')); + $settings['license_app_slug'] = $licenseAppSlug !== '' ? LicenseService::sanitizeAppSlug($licenseAppSlug, true) : ''; $prefixStart = (int)($settings['weight_barcode_prefix_start'] ?? 20); $prefixEnd = (int)($settings['weight_barcode_prefix_end'] ?? 29); if ($prefixStart < 20 || $prefixStart > 29) $prefixStart = 20; @@ -9225,6 +9229,19 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System';
+ 'Saved in settings', + 'environment' => 'Environment variable', + 'derived' => 'Derived from app name', + 'default' => 'Built-in fallback', + ]; + $savedLicenseAppName = trim((string)($data['settings']['license_app_name'] ?? '')); + $savedLicenseAppSlug = trim((string)($data['settings']['license_app_slug'] ?? '')); + $licenseAppNameInput = $savedLicenseAppName; + $licenseAppSlugInput = $savedLicenseAppSlug; + ?>
@@ -9334,6 +9351,51 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System';
+ +
+
+
+ License App Identity +
+ + Name source: + · + Slug source: + +
+
+ Use one stable identity per product. + This is the product identity your central license manager sees. Change the slug only when you intentionally rename or split a product. +
+
+
+ +
+ + +
+
Leave blank only if you want to fall back to environment/default values.
+
+
+ +
+ + + +
+
Use one slug per product, not per customer or installation.
+
+
+
+ Current effective identity: + + · + +
+
+
+
+
@@ -9421,6 +9483,43 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System';
+ diff --git a/lib/LicenseService.php b/lib/LicenseService.php index 00ccf36..859f5ae 100644 --- a/lib/LicenseService.php +++ b/lib/LicenseService.php @@ -2,14 +2,167 @@ class LicenseService { private static $remote_api_url = null; + private static $api_secret = null; + private static $app_slug = null; + private static $app_name = null; + private static $settings_cache = null; + private static $identity_cache = null; private static function getApiUrl() { - if (self::$remote_api_url === null) { - self::$remote_api_url = getenv('LICENSE_API_URL') ?: 'https://omanapp.cloud/meezan_register/'; + if (self::$remote_api_url !== null) { + return self::$remote_api_url; } + + $configured = trim((string)(getenv('LICENSE_API_URL') ?: '')); + if ($configured !== '') { + self::$remote_api_url = rtrim($configured, '/'); + return self::$remote_api_url; + } + + if (file_exists(__DIR__ . '/../central_license_manager/index.php')) { + self::$remote_api_url = 'http://127.0.0.1/central_license_manager'; + return self::$remote_api_url; + } + + self::$remote_api_url = 'https://omanapp.cloud/meezan_register'; return self::$remote_api_url; } + private static function getApiSecret() { + if (self::$api_secret === null) { + $secret = trim((string)(getenv('LICENSE_API_SECRET') ?: getenv('CLM_API_SECRET') ?: '1485-5215-2578')); + self::$api_secret = $secret !== '' ? $secret : '1485-5215-2578'; + } + + return self::$api_secret; + } + + public static function sanitizeAppSlug($value, $allowEmpty = false) { + $value = strtolower(trim((string)$value)); + $value = preg_replace('/[^a-z0-9]+/', '-', $value); + $value = trim((string)$value, '-'); + + if ($value === '') { + return $allowEmpty ? '' : 'bilingual-accounting'; + } + + return substr($value, 0, 120); + } + + private static function loadIdentitySettings() { + if (self::$settings_cache !== null) { + return self::$settings_cache; + } + + self::$settings_cache = [ + 'license_app_name' => null, + 'license_app_slug' => null, + ]; + + try { + require_once __DIR__ . '/../db/config.php'; + $db = db(); + $tableExists = $db->query("SHOW TABLES LIKE 'settings'")->fetch(); + + if ($tableExists) { + $stmt = $db->prepare("SELECT `key`, `value` FROM settings WHERE `key` IN ('license_app_name', 'license_app_slug')"); + $stmt->execute(); + + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $key = (string)($row['key'] ?? ''); + $value = trim((string)($row['value'] ?? '')); + + if (array_key_exists($key, self::$settings_cache) && $value !== '') { + self::$settings_cache[$key] = $value; + } + } + } + } catch (Throwable $e) { + // Ignore local settings lookup errors and fall back to env/default values. + } + + return self::$settings_cache; + } + + private static function resolveClientIdentity() { + if (self::$identity_cache !== null) { + return self::$identity_cache; + } + + $settings = self::loadIdentitySettings(); + $storedName = trim((string)($settings['license_app_name'] ?? '')); + $envName = trim((string)(getenv('LICENSE_APP_NAME') ?: '')); + + if ($storedName !== '') { + $name = $storedName; + $nameSource = 'settings'; + } elseif ($envName !== '') { + $name = $envName; + $nameSource = 'environment'; + } else { + $name = 'Bilingual Accounting'; + $nameSource = 'default'; + } + + $storedSlug = trim((string)($settings['license_app_slug'] ?? '')); + $envSlug = trim((string)(getenv('LICENSE_APP_SLUG') ?: '')); + + if ($storedSlug !== '') { + $slug = self::sanitizeAppSlug($storedSlug); + $slugSource = 'settings'; + } elseif ($envSlug !== '') { + $slug = self::sanitizeAppSlug($envSlug); + $slugSource = 'environment'; + } else { + $slug = self::sanitizeAppSlug($name); + $slugSource = $nameSource === 'default' ? 'default' : 'derived'; + } + + self::$app_name = $name; + self::$app_slug = $slug; + self::$identity_cache = [ + 'app_name' => $name, + 'app_slug' => $slug, + 'app_name_source' => $nameSource, + 'app_slug_source' => $slugSource, + ]; + + return self::$identity_cache; + } + + public static function getClientIdentity() { + return self::resolveClientIdentity(); + } + + private static function slugify($value) { + return self::sanitizeAppSlug($value); + } + + private static function getAppName() { + return (string)(self::resolveClientIdentity()['app_name'] ?? 'Bilingual Accounting'); + } + + private static function getAppSlug() { + return (string)(self::resolveClientIdentity()['app_slug'] ?? 'bilingual-accounting'); + } + + private static function getProductName() { + $name = trim((string)(getenv('LICENSE_PRODUCT_NAME') ?: self::getAppName())); + return $name !== '' ? $name : self::getAppName(); + } + + private static function getProductVersion() { + return trim((string)(getenv('LICENSE_PRODUCT_VERSION') ?: '')); + } + + private static function normalizeAllowedActivations(array $response) { + return max(1, (int)($response['allowed_activations'] ?? $response['max_activations'] ?? 1)); + } + + private static function normalizeMaxCounters(array $response) { + return max(1, (int)($response['max_counters'] ?? self::normalizeAllowedActivations($response))); + } + /** * Returns the number of days remaining in the trial. */ @@ -21,13 +174,12 @@ class LicenseService { $res = $stmt->fetch(); if (!$res || !$res['trial_started_at']) { - // self::startTrial(); // Disabled automatic trial start as per user request to keep things manual - return 0; // Return 0 if trial not started + return 0; } - $started = strtotime($res['trial_started_at']); + $started = strtotime((string)$res['trial_started_at']); $days_elapsed = floor((time() - $started) / (24 * 60 * 60)); - + return (int) max(0, 15 - $days_elapsed); } @@ -38,9 +190,8 @@ class LicenseService { require_once __DIR__ . '/../db/config.php'; try { $db = db(); - // Check if table exists $tableExists = $db->query("SHOW TABLES LIKE 'system_license'")->fetch(); - + if (!$tableExists) { $db->exec("CREATE TABLE system_license ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -49,33 +200,42 @@ class LicenseService { fingerprint VARCHAR(255) DEFAULT NULL, status ENUM('pending', 'active', 'expired', 'suspended', 'trial') DEFAULT 'pending', allowed_activations INT DEFAULT 1, + max_counters INT DEFAULT 1, + app_slug VARCHAR(120) DEFAULT NULL, + app_name VARCHAR(190) DEFAULT NULL, + license_source_url VARCHAR(255) DEFAULT NULL, activated_at DATETIME DEFAULT NULL, last_checked_at DATETIME DEFAULT NULL, trial_started_at DATETIME DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )"); - } else { - // Check if trial_started_at column exists - $stmt = $db->query("SHOW COLUMNS FROM system_license LIKE 'trial_started_at'"); - if (!$stmt->fetch()) { - $db->exec("ALTER TABLE system_license ADD COLUMN trial_started_at DATETIME DEFAULT NULL"); - } + return; + } - // Check if allowed_activations column exists - $stmt = $db->query("SHOW COLUMNS FROM system_license LIKE 'allowed_activations'"); - if (!$stmt->fetch()) { - $db->exec("ALTER TABLE system_license ADD COLUMN allowed_activations INT DEFAULT 1"); - } + $columnChecks = [ + 'trial_started_at' => "ALTER TABLE system_license ADD COLUMN trial_started_at DATETIME DEFAULT NULL", + 'allowed_activations' => "ALTER TABLE system_license ADD COLUMN allowed_activations INT DEFAULT 1", + 'max_counters' => "ALTER TABLE system_license ADD COLUMN max_counters INT DEFAULT 1", + 'app_slug' => "ALTER TABLE system_license ADD COLUMN app_slug VARCHAR(120) DEFAULT NULL", + 'app_name' => "ALTER TABLE system_license ADD COLUMN app_name VARCHAR(190) DEFAULT NULL", + 'license_source_url' => "ALTER TABLE system_license ADD COLUMN license_source_url VARCHAR(255) DEFAULT NULL", + 'last_checked_at' => "ALTER TABLE system_license ADD COLUMN last_checked_at DATETIME DEFAULT NULL", + ]; - // Ensure 'trial' status exists in ENUM - $stmt = $db->query("SHOW COLUMNS FROM system_license LIKE 'status'"); - $statusCol = $stmt->fetch(); - if ($statusCol && strpos($statusCol['Type'], "'trial'") === false) { - $db->exec("ALTER TABLE system_license MODIFY COLUMN status ENUM('pending', 'active', 'expired', 'suspended', 'trial') DEFAULT 'pending'"); + foreach ($columnChecks as $column => $sql) { + $stmt = $db->query("SHOW COLUMNS FROM system_license LIKE '" . addslashes($column) . "'"); + if (!$stmt->fetch()) { + $db->exec($sql); } } + + $stmt = $db->query("SHOW COLUMNS FROM system_license LIKE 'status'"); + $statusCol = $stmt->fetch(); + if ($statusCol && strpos((string)$statusCol['Type'], "'trial'") === false) { + $db->exec("ALTER TABLE system_license MODIFY COLUMN status ENUM('pending', 'active', 'expired', 'suspended', 'trial') DEFAULT 'pending'"); + } } catch (Exception $e) { - // Log or ignore + // Keep licensing checks fail-safe; UI will show remote/local activation errors separately. } } @@ -99,7 +259,7 @@ class LicenseService { self::ensureTrialSchema(); $stmt = db()->prepare("SELECT COUNT(*) FROM system_license"); $stmt->execute(); - if ($stmt->fetchColumn() == 0) { + if ((int)$stmt->fetchColumn() === 0) { db()->exec("INSERT INTO system_license (license_key, status, trial_started_at) VALUES ('TRIAL', 'trial', NOW())"); } else { db()->exec("UPDATE system_license SET trial_started_at = NOW() WHERE trial_started_at IS NULL"); @@ -111,9 +271,9 @@ class LicenseService { */ public static function getFingerprint() { $data = [ - php_uname('n'), // Nodename (hostname) - php_uname('m'), // Machine type - PHP_OS + php_uname('n'), + php_uname('m'), + PHP_OS, ]; return hash('sha256', implode('|', $data)); } @@ -123,16 +283,15 @@ class LicenseService { */ public static function getAllowedActivations() { require_once __DIR__ . '/../db/config.php'; + self::ensureTrialSchema(); $stmt = db()->prepare("SELECT allowed_activations FROM system_license WHERE status = 'active' ORDER BY id DESC LIMIT 1"); $stmt->execute(); $res = $stmt->fetch(); - + if ($res) { return (int)$res['allowed_activations']; } - - // If in trial, allow maybe 1 or 2? Let's say 1 for now or based on user's preference. - // But the user said "same as number of activations". + return 1; } @@ -140,7 +299,9 @@ class LicenseService { * Checks if the system is currently activated or within trial period. */ public static function canAccess() { - if (self::isActivated()) return true; + if (self::isActivated()) { + return true; + } $daysLeft = self::getTrialRemainingDays(); return $daysLeft > 0; @@ -151,36 +312,41 @@ class LicenseService { */ public static function isActivated() { require_once __DIR__ . '/../db/config.php'; + self::ensureTrialSchema(); $fingerprint = self::getFingerprint(); - $stmt = db()->prepare("SELECT * FROM system_license WHERE status = 'active' AND fingerprint = ? LIMIT 1"); + $stmt = db()->prepare("SELECT * FROM system_license WHERE status = 'active' AND fingerprint = ? ORDER BY id DESC LIMIT 1"); $stmt->execute([$fingerprint]); $license = $stmt->fetch(); - if (!$license) return false; - - // 1. Verify fingerprint matches (Physical Protection) - if ($license['fingerprint'] !== $fingerprint) { + if (!$license) { return false; } - // 2. Periodic Remote Validation (e.g., every 7 days) - // This ensures if you disable a key on your server, the client will stop working. - $last_check = strtotime($license['activated_at']); // In real use, store 'last_verified_at' - if (time() - $last_check > (7 * 24 * 60 * 60)) { - // It's been more than 7 days, let's re-verify + if (($license['fingerprint'] ?? '') !== $fingerprint) { + return false; + } + + $lastCheckRaw = $license['last_checked_at'] ?? ($license['activated_at'] ?? null); + $lastCheck = $lastCheckRaw ? strtotime((string)$lastCheckRaw) : false; + if ($lastCheck === false || time() - $lastCheck > (7 * 24 * 60 * 60)) { $res = self::callRemoteApi('/verify', [ - 'license_key' => $license['license_key'], - 'fingerprint' => $license['fingerprint'], - 'token' => $license['activation_token'] + 'license_key' => (string)$license['license_key'], + 'fingerprint' => (string)$license['fingerprint'], + 'token' => (string)($license['activation_token'] ?? ''), + 'app_slug' => (string)($license['app_slug'] ?: self::getAppSlug()), ]); - - if (!$res['success']) { - db()->prepare("UPDATE system_license SET status = 'suspended' WHERE fingerprint = ?")->execute([$fingerprint]); + + if (empty($res['success'])) { + db()->prepare("UPDATE system_license SET status = 'suspended', last_checked_at = NOW() WHERE id = ?")->execute([(int)$license['id']]); return false; } - - // Update last verified timestamp - db()->prepare("UPDATE system_license SET activated_at = NOW() WHERE fingerprint = ?")->execute([$fingerprint]); + + $allowed = self::normalizeAllowedActivations($res); + $maxCounters = self::normalizeMaxCounters($res); + $appSlug = (string)($res['app_slug'] ?? ($license['app_slug'] ?: self::getAppSlug())); + $appName = (string)($res['app_name'] ?? ($license['app_name'] ?: self::getAppName())); + db()->prepare("UPDATE system_license SET status = 'active', allowed_activations = ?, max_counters = ?, app_slug = ?, app_name = ?, license_source_url = ?, last_checked_at = NOW() WHERE id = ?") + ->execute([$allowed, $maxCounters, $appSlug, $appName, self::getApiUrl(), (int)$license['id']]); } return true; @@ -190,59 +356,80 @@ class LicenseService { * Attempts to activate the product online. */ public static function activate($license_key) { - $license_key = trim($license_key); + $license_key = strtoupper(trim((string)$license_key)); + if ($license_key === '') { + return ['success' => false, 'error' => 'License key is required.']; + } + $fingerprint = self::getFingerprint(); - - // Call remote API for real validation + $response = self::callRemoteApi('/activate', [ 'license_key' => $license_key, 'fingerprint' => $fingerprint, 'domain' => $_SERVER['HTTP_HOST'] ?? 'unknown', - 'product' => 'Flatlogic Admin Panel' + 'product' => self::getProductName(), + 'product_version' => self::getProductVersion(), + 'app_slug' => self::getAppSlug(), + 'app_name' => self::getAppName(), ]); - if (!$response['success']) { + if (empty($response['success'])) { return ['success' => false, 'error' => $response['error'] ?? 'Remote activation failed.']; } require_once __DIR__ . '/../db/config.php'; - - // Clear previous entries for THIS fingerprint to avoid duplicates - $stmt = db()->prepare("DELETE FROM system_license WHERE fingerprint = ?"); - $stmt->execute([$fingerprint]); + self::ensureTrialSchema(); - // Check if we reached the limit of activations for this key (in a shared DB) - $allowed = (int) ($response['allowed_activations'] ?? 1); - $stmt = db()->prepare("SELECT COUNT(DISTINCT fingerprint) FROM system_license WHERE license_key = ? AND status = 'active'"); - $stmt->execute([$license_key]); - $current_activations = (int) $stmt->fetchColumn(); + $allowed = self::normalizeAllowedActivations($response); + $maxCounters = self::normalizeMaxCounters($response); + $appSlug = (string)($response['app_slug'] ?? self::getAppSlug()); + $appName = (string)($response['app_name'] ?? self::getAppName()); + $token = (string)($response['activation_token'] ?? bin2hex(random_bytes(32))); - if ($current_activations >= $allowed) { - return [ - 'success' => false, - 'error' => "Activation Limit Reached: This license only allows $allowed machine(s). Please deactivate another machine first." - ]; - } + $stmt = db()->prepare("DELETE FROM system_license WHERE fingerprint = ? OR license_key = ? OR status = 'trial'"); + $stmt->execute([$fingerprint, $license_key]); - $stmt = db()->prepare("INSERT INTO system_license (license_key, fingerprint, status, activated_at, activation_token, allowed_activations) VALUES (?, ?, 'active', NOW(), ?, ?)"); - $token = $response['activation_token'] ?? bin2hex(random_bytes(32)); - $stmt->execute([$license_key, $fingerprint, $token, $allowed]); + $stmt = db()->prepare("INSERT INTO system_license (license_key, fingerprint, status, activated_at, last_checked_at, activation_token, allowed_activations, max_counters, app_slug, app_name, license_source_url, trial_started_at) VALUES (?, ?, 'active', NOW(), NOW(), ?, ?, ?, ?, ?, ?, NULL)"); + $stmt->execute([ + $license_key, + $fingerprint, + $token, + $allowed, + $maxCounters, + $appSlug, + $appName, + self::getApiUrl(), + ]); - return ['success' => true]; + return [ + 'success' => true, + 'app_slug' => $appSlug, + 'app_name' => $appName, + 'allowed_activations' => $allowed, + 'max_counters' => $maxCounters, + ]; } /** * Fetches all licenses from the remote server. */ public static function listLicenses() { - return self::callRemoteApi('/list', []); + return self::callRemoteApi('/list', [ + 'secret' => self::getApiSecret(), + 'app_slug' => self::getAppSlug(), + ]); } /** * Updates an existing license. */ public static function updateLicense($id, $data) { - $params = array_merge(['id' => $id, 'secret' => '1485-5215-2578'], $data); + $params = array_merge([ + 'id' => $id, + 'secret' => self::getApiSecret(), + 'app_slug' => self::getAppSlug(), + ], is_array($data) ? $data : []); + return self::callRemoteApi('/update', $params); } @@ -250,27 +437,38 @@ class LicenseService { * Issues a new license. */ public static function issueLicense($max_activations, $prefix = 'FLAT', $owner = null, $address = null) { - return self::callRemoteApi('/issue', [ - 'secret' => '1485-5215-2578', - 'max_activations' => $max_activations, + $payload = [ + 'secret' => self::getApiSecret(), + 'max_activations' => max(1, (int)$max_activations), + 'max_counters' => max(1, (int)$max_activations), 'prefix' => $prefix, 'owner' => $owner, - 'address' => $address - ]); + 'address' => $address, + 'app_slug' => self::getAppSlug(), + 'app_name' => self::getAppName(), + ]; + + return self::callRemoteApi('/issue', $payload); } /** - * Remote API Caller + * Remote API Caller. */ private static function callRemoteApi($endpoint, $params) { - $action = ltrim($endpoint, '/'); - $url = rtrim(self::getApiUrl(), '/') . '/index.php?action=' . $action; - - // Check if we are in local development / simulation mode - if (strpos($url, 'your-domain.com') !== false) { + $action = ltrim((string)$endpoint, '/'); + $baseUrl = self::getApiUrl(); + + if ($baseUrl === '' || strpos($baseUrl, 'your-domain.com') !== false) { return self::simulateApi($endpoint, $params); } + if (strpos($baseUrl, 'index.php') !== false) { + $separator = strpos($baseUrl, '?') === false ? '?' : '&'; + $url = $baseUrl . $separator . 'action=' . urlencode($action); + } else { + $url = rtrim($baseUrl, '/') . '/index.php?action=' . urlencode($action); + } + $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); @@ -278,52 +476,94 @@ class LicenseService { curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); curl_setopt($ch, CURLOPT_TIMEOUT, 10); $resp = curl_exec($ch); - $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $http_code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curl_error = curl_error($ch); curl_close($ch); - if ($http_code !== 200) { - return ['success' => false, 'error' => "Remote server returned error code $http_code."]; + if ($resp === false) { + return ['success' => false, 'error' => 'Remote request failed: ' . $curl_error]; } $data = json_decode($resp, true); - if (!$data) { - return ['success' => false, 'error' => "Invalid response from remote server."]; + if (!is_array($data)) { + return ['success' => false, 'error' => 'Invalid response from remote server.']; + } + + if ($http_code < 200 || $http_code >= 300) { + return [ + 'success' => false, + 'error' => (string)($data['error'] ?? ('Remote server returned error code ' . $http_code . '.')), + 'http_code' => $http_code, + ]; } return $data; } /** - * Local Simulation for development purposes + * Local simulation for development purposes. */ private static function simulateApi($endpoint, $params) { - $clean_key = strtoupper(trim($params['license_key'] ?? '')); - - // Strict format check: FLAT-XXXX-XXXX-XXXX (where X is hex) + $clean_key = strtoupper(trim((string)($params['license_key'] ?? ''))); $pattern = '/^FLAT-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/'; - - if (!preg_match($pattern, $clean_key)) { + + if ($endpoint === '/list') { + return ['success' => true, 'data' => []]; + } + + if ($endpoint === '/apps') { + return ['success' => true, 'data' => [['slug' => self::getAppSlug(), 'name' => self::getAppName()]]]; + } + + if ($endpoint === '/update') { + return ['success' => true, 'data' => $params]; + } + + if ($endpoint === '/issue') { return [ - 'success' => false, - 'error' => 'Invalid License Format. Expected FLAT-XXXX-XXXX-XXXX (Simulation Mode).' + 'success' => true, + 'license_key' => 'FLAT-' . strtoupper(bin2hex(random_bytes(2))) . '-' . strtoupper(bin2hex(random_bytes(2))) . '-' . strtoupper(bin2hex(random_bytes(2))), + 'max_activations' => max(1, (int)($params['max_activations'] ?? 1)), + 'allowed_activations' => max(1, (int)($params['max_activations'] ?? 1)), + 'max_counters' => max(1, (int)($params['max_counters'] ?? $params['max_activations'] ?? 1)), + 'app_slug' => self::getAppSlug(), + 'app_name' => self::getAppName(), + ]; + } + + if (!preg_match($pattern, $clean_key)) { + return [ + 'success' => false, + 'error' => 'Invalid License Format. Expected FLAT-XXXX-XXXX-XXXX (Simulation Mode).', ]; } - // In a real server, you would check if this key is already used by a DIFFERENT fingerprint. - // To simulate a "Max Activations Reached" error, you can use a specific key: if ($clean_key === 'FLAT-0000-0000-0000') { return [ 'success' => false, - 'error' => 'Activation Failed: This license key is already active on another machine.' + 'error' => 'Activation Failed: This license key is already active on another machine.', + ]; + } + + if ($endpoint === '/verify') { + return [ + 'success' => true, + 'max_activations' => 2, + 'allowed_activations' => 2, + 'max_counters' => 2, + 'app_slug' => self::getAppSlug(), + 'app_name' => self::getAppName(), ]; } - if ($endpoint === '/verify') return ['success' => true]; - return [ 'success' => true, - 'allowed_activations' => 2, // Simulate a license that allows 2 machines/counters - 'activation_token' => hash('sha256', $params['license_key'] . $params['fingerprint'] . 'DEBUG_SALT') + 'allowed_activations' => 2, + 'max_activations' => 2, + 'max_counters' => 2, + 'activation_token' => hash('sha256', $clean_key . (string)($params['fingerprint'] ?? '') . 'DEBUG_SALT'), + 'app_slug' => self::getAppSlug(), + 'app_name' => self::getAppName(), ]; } }