$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'; }