2026-05-02 07:09:31 +00:00

653 lines
21 KiB
PHP

<?php
require_once __DIR__ . '/config.php';
if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
@session_start();
}
function clm_cfg($key = null)
{
$config = clm_config();
return $key === null ? $config : ($config[$key] ?? null);
}
function clm_safe_identifier($value): string
{
return preg_replace('/[^a-zA-Z0-9_]/', '', (string)$value);
}
function clm_tables(): array
{
$prefix = clm_safe_identifier(clm_cfg('table_prefix') ?: 'clm_');
return [
'apps' => $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';
}