updating activation management

This commit is contained in:
Flatlogic Bot 2026-05-02 07:09:31 +00:00
parent 18ae044a97
commit 9e51645c2f
8 changed files with 2627 additions and 110 deletions

View File

@ -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.

View File

@ -0,0 +1,652 @@
<?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';
}

View File

@ -0,0 +1,33 @@
<?php
/**
* Central License Manager configuration.
*
* When this folder lives inside the main app repo it can reuse the parent
* project database automatically. If you move the folder elsewhere, either:
* 1) set CLM_DB_* environment variables, or
* 2) edit the DB values below.
*/
function clm_config(): array
{
static $config = null;
if ($config !== null) {
return $config;
}
$config = [
'manager_name' => 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;
}

View File

@ -0,0 +1,473 @@
<?php
require_once __DIR__ . '/bootstrap.php';
$schemaError = '';
try {
clm_ensure_schema();
} catch (Throwable $e) {
$schemaError = $e->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();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Central License Manager | Multi-App Activation Hub</title>
<meta name="description" content="Standalone multi-app license and activation manager for your accounting, POS, CRM, and admin panel projects.">
<meta name="robots" content="noindex, nofollow">
<style>
:root {
--bg: #f6f8fb;
--card: rgba(255, 255, 255, 0.92);
--text: #0f172a;
--muted: #64748b;
--line: #d9e2ec;
--accent: #0ea5e9;
--accent-strong: #0284c7;
--success: #16a34a;
--danger: #dc2626;
--shadow: 0 20px 45px rgba(15, 23, 42, 0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background:
radial-gradient(circle at top left, rgba(14, 165, 233, 0.16), transparent 28%),
radial-gradient(circle at top right, rgba(34, 197, 94, 0.08), transparent 24%),
var(--bg);
color: var(--text);
}
a { color: var(--accent-strong); text-decoration: none; }
a:hover { text-decoration: underline; }
.shell { max-width: 1180px; margin: 0 auto; padding: 32px 20px 48px; }
.hero {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.8fr);
gap: 20px;
align-items: stretch;
}
.panel {
background: var(--card);
border: 1px solid rgba(217, 226, 236, 0.9);
border-radius: 24px;
padding: 24px;
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(14, 165, 233, 0.09);
color: var(--accent-strong);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
}
h1, h2, h3 { margin: 0 0 12px; }
h1 { font-size: clamp(2rem, 4vw, 3.3rem); line-height: 1.02; }
p { margin: 0 0 14px; color: var(--muted); line-height: 1.6; }
.lede { font-size: 1.05rem; max-width: 60ch; }
.stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 24px;
}
.stat {
padding: 16px;
border-radius: 20px;
background: rgba(255,255,255,0.9);
border: 1px solid rgba(217, 226, 236, 0.9);
}
.stat strong { display: block; font-size: 1.8rem; margin-bottom: 6px; }
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
margin-top: 20px;
}
.code {
display: block;
padding: 14px 16px;
border-radius: 18px;
background: #0f172a;
color: #e2e8f0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
overflow-x: auto;
font-size: 13px;
}
.list {
padding-left: 18px;
color: var(--muted);
margin: 0;
}
.list li { margin-bottom: 10px; }
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 18px;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 18px;
border-radius: 16px;
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: white;
font-weight: 700;
box-shadow: 0 12px 24px rgba(2, 132, 199, 0.22);
}
.button.alt {
background: white;
color: var(--text);
border: 1px solid var(--line);
box-shadow: none;
}
.status-ok { color: var(--success); font-weight: 700; }
.status-bad { color: var(--danger); font-weight: 700; }
@media (max-width: 960px) {
.hero, .grid, .stats { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="shell">
<section class="hero">
<article class="panel">
<span class="badge">Standalone module</span>
<h1>One activation hub for all your apps.</h1>
<p class="lede">This folder is isolated from the legacy <code>license_manager/</code> code, auto-creates its own multi-app tables, and can be copied to another server later without rewriting the rest of your app first.</p>
<div class="stats">
<div class="stat">
<strong><?= (int)$stats['apps'] ?></strong>
<span>Apps</span>
</div>
<div class="stat">
<strong><?= (int)$stats['licenses'] ?></strong>
<span>Licenses</span>
</div>
<div class="stat">
<strong><?= (int)$stats['activations'] ?></strong>
<span>Active installs</span>
</div>
</div>
<div class="actions">
<a class="button" href="manage.php">Open manager</a>
<a class="button alt" href="install.php">Open installer</a>
</div>
</article>
<aside class="panel">
<h2>Status</h2>
<?php if ($schemaError === ''): ?>
<p class="status-ok">Ready <?= clm_html(clm_manager_mode_label()) ?></p>
<?php else: ?>
<p class="status-bad">Needs configuration</p>
<p><?= clm_html($schemaError) ?></p>
<?php endif; ?>
<p><strong>Module base URL</strong></p>
<span class="code"><?= clm_html($baseUrl) ?></span>
<p style="margin-top: 16px;"><strong>Client setting</strong></p>
<span class="code">LICENSE_API_URL=<?= clm_html($baseUrl) ?></span>
<p style="margin-top: 16px;">Old clients that only send <code>product</code> still work. Newer clients can send <code>app_slug</code> explicitly for stricter app separation.</p>
</aside>
</section>
<section class="grid">
<article class="panel">
<h2>What this module adds</h2>
<ul class="list">
<li>Dedicated <code>clm_apps</code>, <code>clm_licenses</code>, and <code>clm_activations</code> tables.</li>
<li>Backward-compatible API responses for existing clients expecting <code>allowed_activations</code>.</li>
<li>Move-friendly structure so you can copy this folder to a separate host later.</li>
</ul>
</article>
<article class="panel">
<h2>Quick start</h2>
<ol class="list">
<li>Open <a href="install.php">install.php</a> once.</li>
<li>Open <a href="manage.php">manage.php</a> and create your app records.</li>
<li>Point each client app to this folder through <code>LICENSE_API_URL</code>.</li>
</ol>
</article>
<article class="panel">
<h2>Available endpoints</h2>
<ul class="list">
<li><code>index.php?action=health</code></li>
<li><code>index.php?action=activate</code></li>
<li><code>index.php?action=verify</code></li>
<li><code>index.php?action=deactivate</code></li>
<li><code>index.php?action=issue</code> / <code>list</code> / <code>update</code></li>
</ul>
</article>
</section>
</main>
</body>
</html>

View File

@ -0,0 +1,147 @@
<?php
require_once __DIR__ . '/bootstrap.php';
$statusCode = 200;
$result = [
'success' => 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();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Install Central License Manager</title>
<meta name="description" content="Install and validate the standalone Central License Manager schema.">
<meta name="robots" content="noindex, nofollow">
<style>
:root {
--bg: #f7f9fc;
--card: #ffffff;
--text: #0f172a;
--muted: #64748b;
--line: #d9e2ec;
--accent: #0ea5e9;
--good: #15803d;
--bad: #b91c1c;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: radial-gradient(circle at top left, rgba(14,165,233,0.14), transparent 26%), var(--bg);
color: var(--text);
}
main { max-width: 960px; margin: 0 auto; padding: 32px 20px 48px; }
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 24px;
padding: 24px;
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.07);
margin-bottom: 20px;
}
h1, h2 { margin: 0 0 12px; }
p { color: var(--muted); line-height: 1.65; }
.status { font-weight: 800; }
.ok { color: var(--good); }
.bad { color: var(--bad); }
.code {
display: block;
margin-top: 12px;
background: #0f172a;
color: #e2e8f0;
padding: 14px 16px;
border-radius: 18px;
overflow-x: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
}
ul { color: var(--muted); padding-left: 20px; }
a {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 18px;
border-radius: 16px;
background: linear-gradient(135deg, #0ea5e9, #0284c7);
color: white;
text-decoration: none;
font-weight: 700;
margin-right: 10px;
}
a.alt {
background: white;
color: var(--text);
border: 1px solid var(--line);
}
</style>
</head>
<body>
<main>
<section class="card">
<h1>Central License Manager installer</h1>
<p class="status <?= !empty($result['success']) ? 'ok' : 'bad' ?>">
<?= !empty($result['success']) ? 'Schema ready' : 'Schema check failed' ?>
</p>
<p><?= clm_html($result['message']) ?></p>
<div>
<a href="manage.php">Open manager</a>
<a class="alt" href="index.php">Back to landing</a>
</div>
</section>
<section class="card">
<h2>Current configuration mode</h2>
<p><?= clm_html(clm_manager_mode_label()) ?></p>
<span class="code">LICENSE_API_URL=<?= clm_html($baseUrl) ?></span>
</section>
<section class="card">
<h2>Managed tables</h2>
<ul>
<li><code><?= clm_html($tables['apps']) ?></code></li>
<li><code><?= clm_html($tables['licenses']) ?></code></li>
<li><code><?= clm_html($tables['activations']) ?></code></li>
</ul>
</section>
<section class="card">
<h2>Move this folder later</h2>
<ul>
<li>Copy the whole <code>central_license_manager/</code> folder to the new host.</li>
<li>Set <code>CLM_DB_*</code>, <code>CLM_API_SECRET</code>, and <code>CLM_ADMIN_PASSWORD</code> there.</li>
<li>Open this page once on the new server to auto-create the schema again.</li>
<li>Point each app to the new base URL using <code>LICENSE_API_URL</code>.</li>
</ul>
</section>
</main>
</body>
</html>

View File

@ -0,0 +1,797 @@
<?php
require_once __DIR__ . '/bootstrap.php';
function clm_local_datetime_value($value): string
{
if (empty($value)) {
return '';
}
$time = strtotime((string)$value);
return $time ? date('Y-m-d\TH:i', $time) : '';
}
$schemaError = '';
try {
clm_ensure_schema();
} catch (Throwable $e) {
$schemaError = $e->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):
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Central License Manager Login</title>
<meta name="description" content="Admin login for the Central License Manager.">
<meta name="robots" content="noindex, nofollow">
<style>
:root {
--bg: #f6f8fb;
--card: rgba(255,255,255,0.94);
--text: #0f172a;
--muted: #64748b;
--line: #d9e2ec;
--accent: #0ea5e9;
--danger: #b91c1c;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 20px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: radial-gradient(circle at top left, rgba(14,165,233,0.14), transparent 24%), var(--bg);
color: var(--text);
}
.card {
width: min(100%, 460px);
background: var(--card);
border: 1px solid var(--line);
border-radius: 28px;
padding: 28px;
box-shadow: 0 24px 54px rgba(15, 23, 42, 0.09);
}
.badge {
display: inline-flex;
padding: 8px 12px;
border-radius: 999px;
background: rgba(14,165,233,0.09);
color: #0284c7;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
}
h1 { margin: 14px 0 10px; font-size: 2rem; }
p { color: var(--muted); line-height: 1.65; }
label { display: block; font-size: 0.9rem; font-weight: 700; margin: 14px 0 8px; }
input {
width: 100%;
border: 1px solid var(--line);
border-radius: 16px;
padding: 13px 14px;
font: inherit;
}
button, a.button {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 16px;
padding: 13px 16px;
border-radius: 16px;
border: none;
background: linear-gradient(135deg, #0ea5e9, #0284c7);
color: white;
font: inherit;
font-weight: 800;
text-decoration: none;
cursor: pointer;
}
.alt {
background: white;
color: var(--text);
border: 1px solid var(--line);
}
.error {
margin-top: 14px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(185, 28, 28, 0.08);
color: var(--danger);
border: 1px solid rgba(185, 28, 28, 0.15);
}
code {
display: block;
margin-top: 12px;
padding: 12px 14px;
border-radius: 16px;
background: #0f172a;
color: #e2e8f0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
overflow-x: auto;
}
</style>
</head>
<body>
<main class="card">
<span class="badge">Admin access</span>
<h1>Central License Manager</h1>
<p>Use the configured admin password to manage apps, licenses, and activations from one place.</p>
<code>Mode: <?= clm_html(clm_manager_mode_label()) ?></code>
<?php if ($error !== ''): ?>
<div class="error"><?= clm_html($error) ?></div>
<?php endif; ?>
<form method="post">
<input type="hidden" name="action" value="login">
<label for="password">Admin password</label>
<input id="password" type="password" name="password" required autofocus>
<button type="submit">Login</button>
</form>
<a class="button alt" href="index.php">Back to module home</a>
</main>
</body>
</html>
<?php
exit;
endif;
$tables = clm_tables();
$appsTable = $tables['apps'];
$licensesTable = $tables['licenses'];
$activationsTable = $tables['activations'];
$stats = [
'apps' => 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();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Central License Manager Dashboard</title>
<meta name="description" content="Manage apps, licenses, and activations from a single central dashboard.">
<meta name="robots" content="noindex, nofollow">
<style>
:root {
--bg: #f6f8fb;
--card: rgba(255,255,255,0.93);
--text: #0f172a;
--muted: #64748b;
--line: #d9e2ec;
--accent: #0ea5e9;
--accent-strong: #0284c7;
--success: #15803d;
--danger: #b91c1c;
--warning: #b45309;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background:
radial-gradient(circle at top left, rgba(14,165,233,0.14), transparent 24%),
radial-gradient(circle at top right, rgba(34,197,94,0.08), transparent 20%),
var(--bg);
color: var(--text);
}
.shell { max-width: 1320px; margin: 0 auto; padding: 28px 20px 48px; }
.card {
background: var(--card);
border: 1px solid rgba(217,226,236,0.95);
border-radius: 24px;
padding: 22px;
box-shadow: 0 22px 50px rgba(15, 23, 42, 0.07);
backdrop-filter: blur(10px);
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(320px, 0.8fr);
gap: 20px;
margin-bottom: 20px;
}
.badge {
display: inline-flex;
padding: 8px 12px;
border-radius: 999px;
background: rgba(14,165,233,0.09);
color: var(--accent-strong);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
h1, h2, h3 { margin: 0 0 12px; }
h1 { font-size: clamp(2rem, 3.4vw, 3.1rem); line-height: 1.02; }
p { margin: 0 0 12px; color: var(--muted); line-height: 1.65; }
.top-actions, .row-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.button, button {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 16px;
padding: 12px 16px;
font: inherit;
font-weight: 800;
cursor: pointer;
text-decoration: none;
background: linear-gradient(135deg, #0ea5e9, #0284c7);
color: white;
}
.button.alt, button.alt {
background: white;
color: var(--text);
border: 1px solid var(--line);
}
.stats, .forms {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.forms { grid-template-columns: 1fr 1.2fr 1fr; }
.stat strong { display: block; font-size: 1.8rem; margin-bottom: 6px; }
.code {
display: block;
margin-top: 12px;
padding: 14px 16px;
border-radius: 18px;
background: #0f172a;
color: #e2e8f0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
overflow-x: auto;
}
.notice {
margin-bottom: 18px;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid transparent;
}
.notice.success { background: rgba(21,128,61,0.09); color: var(--success); border-color: rgba(21,128,61,0.16); }
.notice.error { background: rgba(185,28,28,0.08); color: var(--danger); border-color: rgba(185,28,28,0.16); }
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
label {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 0.88rem;
font-weight: 700;
color: var(--text);
}
input, select, textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px 13px;
font: inherit;
background: white;
color: var(--text);
}
textarea { min-height: 108px; resize: vertical; }
.section { margin-top: 20px; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 760px; }
.table-input, .table-select {
min-width: 180px;
padding: 10px 12px;
}
.table-actions { white-space: nowrap; }
th, td {
padding: 13px 12px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
font-size: 0.95rem;
}
th { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; }
.licenses {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.license-card {
background: rgba(255,255,255,0.98);
border: 1px solid var(--line);
border-radius: 22px;
padding: 18px;
}
.license-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.pill {
display: inline-flex;
align-items: center;
padding: 7px 11px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.pill.active { background: rgba(21,128,61,0.1); color: var(--success); }
.pill.suspended { background: rgba(180,83,9,0.1); color: var(--warning); }
.pill.expired, .pill.deactivated { background: rgba(185,28,28,0.1); color: var(--danger); }
.meta { color: var(--muted); font-size: 0.9rem; }
.split { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
.split > * { flex: 1 1 180px; }
.small-form { display: inline; }
.small-btn {
border: 1px solid var(--line);
background: white;
color: var(--text);
padding: 8px 12px;
border-radius: 12px;
font-weight: 700;
}
@media (max-width: 1120px) {
.hero, .forms, .stats, .licenses { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.field-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="shell">
<section class="hero">
<article class="card">
<span class="badge">Multi-app dashboard</span>
<h1><?= clm_html((string)clm_cfg('manager_name')) ?></h1>
<p>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.</p>
<div class="top-actions">
<a class="button" href="index.php">Module home</a>
<a class="button alt" href="install.php">Installer</a>
<a class="button alt" href="manage.php?logout=1">Logout</a>
</div>
</article>
<aside class="card">
<h2>Integration</h2>
<p><?= clm_html(clm_manager_mode_label()) ?></p>
<span class="code">LICENSE_API_URL=<?= clm_html($baseUrl) ?></span>
<p style="margin-top: 12px;">Change <code>CLM_API_SECRET</code> and <code>CLM_ADMIN_PASSWORD</code> before exposing this module publicly.</p>
</aside>
</section>
<?php if ($flash): ?>
<div class="notice <?= $flash['type'] === 'success' ? 'success' : 'error' ?>">
<?= clm_html($flash['message']) ?>
</div>
<?php endif; ?>
<section class="stats">
<article class="card stat">
<strong><?= (int)$stats['apps'] ?></strong>
<span>Apps</span>
</article>
<article class="card stat">
<strong><?= (int)$stats['licenses'] ?></strong>
<span>Licenses</span>
</article>
<article class="card stat">
<strong><?= (int)$stats['activations'] ?></strong>
<span>Active installs</span>
</article>
<article class="card stat">
<strong><?= clm_html(date('Y-m-d')) ?></strong>
<span>Manager date</span>
</article>
</section>
<section class="forms">
<article class="card">
<h2>Create app</h2>
<form method="post">
<input type="hidden" name="action" value="create_app">
<label>App name
<input type="text" name="name" placeholder="Bilingual Accounting">
</label>
<label>Slug
<input type="text" name="slug" placeholder="bilingual-accounting">
</label>
<p class="meta">You can rename the app or adjust its slug later in the Apps table.</p>
<button type="submit">Save app</button>
</form>
</article>
<article class="card">
<h2>Issue license</h2>
<form method="post">
<input type="hidden" name="action" value="issue_license">
<div class="field-grid">
<label>App
<select name="app_slug">
<?php foreach ($apps as $app): ?>
<option value="<?= clm_html($app['slug']) ?>"><?= clm_html($app['name']) ?> (<?= clm_html($app['slug']) ?>)</option>
<?php endforeach; ?>
</select>
</label>
<label>Key prefix
<input type="text" name="prefix" value="FLAT">
</label>
<label>Owner
<input type="text" name="owner" placeholder="Company owner">
</label>
<label>Customer name
<input type="text" name="customer_name" placeholder="Client name">
</label>
<label>Customer email
<input type="email" name="customer_email" placeholder="client@example.com">
</label>
<label>Max activations
<input type="number" name="max_activations" value="1" min="1">
</label>
<label>Max counters
<input type="number" name="max_counters" value="1" min="1">
</label>
<label>Expires at
<input type="datetime-local" name="expires_at">
</label>
</div>
<label>Notes
<textarea name="notes" placeholder="Optional internal note"></textarea>
</label>
<button type="submit">Issue license</button>
</form>
</article>
<article class="card">
<h2>What to point clients to</h2>
<p>Each app should call this module, not keep its own separate activation database.</p>
<span class="code"><?= clm_html($baseUrl) ?></span>
<p style="margin-top: 14px;">Current API stays backward compatible with older clients by returning both <code>max_activations</code> and <code>allowed_activations</code>.</p>
</article>
</section>
<section class="section card">
<h2>Apps</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Status</th>
<th>Licenses</th>
<th>Active installs</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<?php if (!$apps): ?>
<tr><td colspan="6">No apps yet.</td></tr>
<?php else: ?>
<?php foreach ($apps as $app): ?>
<?php $appFormId = 'app-form-' . (int)$app['id']; ?>
<tr>
<td>
<form id="<?= clm_html($appFormId) ?>" method="post">
<input type="hidden" name="action" value="save_app">
<input type="hidden" name="id" value="<?= (int)$app['id'] ?>">
</form>
<input form="<?= clm_html($appFormId) ?>" class="table-input" type="text" name="name" value="<?= clm_html($app['name']) ?>">
</td>
<td>
<input form="<?= clm_html($appFormId) ?>" class="table-input" type="text" name="slug" value="<?= clm_html($app['slug']) ?>">
<div class="meta">Update client settings too if you rename this slug.</div>
</td>
<td>
<select form="<?= clm_html($appFormId) ?>" class="table-select" name="status">
<?php foreach (['active', 'inactive'] as $status): ?>
<option value="<?= $status ?>" <?= $app['status'] === $status ? 'selected' : '' ?>><?= ucfirst($status) ?></option>
<?php endforeach; ?>
</select>
</td>
<td><?= (int)$app['licenses_count'] ?></td>
<td><?= (int)$app['active_activations'] ?></td>
<td class="table-actions">
<button form="<?= clm_html($appFormId) ?>" class="small-btn" type="submit">Save</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="section">
<h2>Licenses</h2>
<div class="licenses">
<?php if (!$licenses): ?>
<article class="card"><p>No licenses yet.</p></article>
<?php else: ?>
<?php foreach ($licenses as $license): ?>
<article class="license-card">
<div class="license-head">
<div>
<strong><code><?= clm_html($license['license_key']) ?></code></strong>
<div class="meta"><?= clm_html($license['app_name']) ?> · <?= clm_html($license['app_slug']) ?></div>
</div>
<span class="pill <?= clm_html($license['status']) ?>"><?= clm_html($license['status']) ?></span>
</div>
<form method="post">
<input type="hidden" name="action" value="save_license">
<input type="hidden" name="id" value="<?= (int)$license['id'] ?>">
<div class="field-grid">
<label>Owner
<input type="text" name="owner" value="<?= clm_html($license['owner']) ?>">
</label>
<label>Customer name
<input type="text" name="customer_name" value="<?= clm_html($license['customer_name']) ?>">
</label>
<label>Customer email
<input type="email" name="customer_email" value="<?= clm_html($license['customer_email']) ?>">
</label>
<label>App
<select name="app_slug">
<?php foreach ($apps as $appOption): ?>
<option value="<?= clm_html($appOption['slug']) ?>" <?= $license['app_slug'] === $appOption['slug'] ? 'selected' : '' ?>><?= clm_html($appOption['name']) ?> (<?= clm_html($appOption['slug']) ?>)</option>
<?php endforeach; ?>
</select>
</label>
<label>Max activations
<input type="number" name="max_activations" min="1" value="<?= (int)$license['max_activations'] ?>">
</label>
<label>Max counters
<input type="number" name="max_counters" min="1" value="<?= (int)$license['max_counters'] ?>">
</label>
<label>Expires at
<input type="datetime-local" name="expires_at" value="<?= clm_html(clm_local_datetime_value($license['expires_at'])) ?>">
</label>
<label>Status
<select name="status">
<?php foreach (['active', 'suspended', 'expired'] as $status): ?>
<option value="<?= $status ?>" <?= $license['status'] === $status ? 'selected' : '' ?>><?= ucfirst($status) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>Notes
<textarea name="notes"><?= clm_html($license['notes']) ?></textarea>
</label>
</div>
<div class="split">
<div class="meta">Created: <?= clm_html((string)$license['created_at']) ?></div>
<div class="meta">Active installs: <?= (int)$license['active_activations'] ?></div>
</div>
<div class="row-actions" style="margin-top: 14px;">
<button type="submit">Save license</button>
</div>
</form>
<form method="post" class="row-actions" style="margin-top: 10px;">
<input type="hidden" name="action" value="toggle_license">
<input type="hidden" name="id" value="<?= (int)$license['id'] ?>">
<button class="alt" type="submit"><?= $license['status'] === 'active' ? 'Suspend' : 'Activate' ?></button>
</form>
</article>
<?php endforeach; ?>
<?php endif; ?>
</div>
</section>
<section class="section card">
<h2>Recent activations</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>License</th>
<th>App</th>
<th>Domain</th>
<th>Fingerprint</th>
<th>Product</th>
<th>Status</th>
<th>Activated</th>
<th>Last seen</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<?php if (!$activations): ?>
<tr><td colspan="9">No activations yet.</td></tr>
<?php else: ?>
<?php foreach ($activations as $activation): ?>
<tr>
<td><code><?= clm_html($activation['license_key']) ?></code></td>
<td><?= clm_html($activation['app_name']) ?></td>
<td><?= clm_html($activation['domain']) ?></td>
<td><code><?= clm_html(substr((string)$activation['fingerprint'], 0, 16)) ?>…</code></td>
<td><?= clm_html($activation['product']) ?></td>
<td><span class="pill <?= clm_html($activation['status']) ?>"><?= clm_html($activation['status']) ?></span></td>
<td><?= clm_html((string)$activation['activated_at']) ?></td>
<td><?= clm_html((string)($activation['last_seen_at'] ?: '—')) ?></td>
<td>
<?php if (($activation['status'] ?? 'active') === 'active'): ?>
<form method="post" class="small-form">
<input type="hidden" name="action" value="deactivate_activation">
<input type="hidden" name="id" value="<?= (int)$activation['id'] ?>">
<button class="small-btn" type="submit">Deactivate</button>
</form>
<?php else: ?>
<span class="meta">Inactive</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>

View File

@ -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';
</div>
</div>
<div class="card-body p-4">
<?php
$licenseIdentity = LicenseService::getClientIdentity();
$licenseSourceLabels = [
'settings' => '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;
?>
<form method="POST" enctype="multipart/form-data">
<!-- Company Details Section -->
@ -9334,6 +9351,51 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System';
</div>
</div>
<!-- License Identity Section -->
<div class="mb-5">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-3 border-bottom pb-2">
<h6 class="fw-bold text-primary m-0">
<i class="bi bi-shield-check me-2"></i><span data-en="License App Identity" data-ar="هوية ترخيص التطبيق">License App Identity</span>
</h6>
<span class="badge rounded-pill text-bg-light border">
<span data-en="Name source" data-ar="مصدر الاسم">Name source</span>: <?= htmlspecialchars($licenseSourceLabels[$licenseIdentity['app_name_source']] ?? 'Built-in fallback') ?>
·
<span data-en="Slug source" data-ar="مصدر المعرّف">Slug source</span>: <?= htmlspecialchars($licenseSourceLabels[$licenseIdentity['app_slug_source']] ?? 'Built-in fallback') ?>
</span>
</div>
<div class="alert alert-info border-0 shadow-sm small mb-3">
<strong data-en="Use one stable identity per product." data-ar="استخدم هوية ثابتة لكل منتج.">Use one stable identity per product.</strong>
<span data-en="This is the product identity your central license manager sees. Change the slug only when you intentionally rename or split a product." data-ar="هذه هي هوية المنتج التي يراها مدير التراخيص المركزي. غيّر المعرّف فقط عندما تقصد إعادة تسمية المنتج أو تقسيمه.">This is the product identity your central license manager sees. Change the slug only when you intentionally rename or split a product.</span>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label text-muted small fw-semibold" data-en="App Name for Licensing" data-ar="اسم التطبيق للترخيص">App Name for Licensing</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-window"></i></span>
<input type="text" id="license-app-name" name="settings[license_app_name]" class="form-control border-start-0 ps-0" value="<?= htmlspecialchars($licenseAppNameInput) ?>" placeholder="<?= htmlspecialchars((string)$licenseIdentity['app_name']) ?>">
</div>
<div class="form-text" data-en="Leave blank only if you want to fall back to environment/default values." data-ar="اتركه فارغًا فقط إذا كنت تريد الرجوع إلى قيم البيئة أو القيم الافتراضية.">Leave blank only if you want to fall back to environment/default values.</div>
</div>
<div class="col-md-6">
<label class="form-label text-muted small fw-semibold" data-en="Stable App Slug" data-ar="المعرّف الثابت للتطبيق">Stable App Slug</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-tag"></i></span>
<input type="text" id="license-app-slug" name="settings[license_app_slug]" data-explicit="<?= $savedLicenseAppSlug !== '' ? '1' : '0' ?>" class="form-control border-start-0 ps-0" value="<?= htmlspecialchars($licenseAppSlugInput) ?>" placeholder="<?= htmlspecialchars((string)$licenseIdentity['app_slug']) ?>">
<button type="button" class="btn btn-outline-secondary" id="suggest-license-slug" data-en="Suggest" data-ar="اقتراح">Suggest</button>
</div>
<div class="form-text" data-en="Use one slug per product, not per customer or installation." data-ar="استخدم معرّفًا واحدًا لكل منتج، وليس لكل عميل أو تثبيت.">Use one slug per product, not per customer or installation.</div>
</div>
<div class="col-12">
<div class="form-text">
<span data-en="Current effective identity:" data-ar="هوية التطبيق الفعالة حاليًا:">Current effective identity:</span>
<code><?= htmlspecialchars((string)$licenseIdentity['app_name']) ?></code>
·
<code><?= htmlspecialchars((string)$licenseIdentity['app_slug']) ?></code>
</div>
</div>
</div>
</div>
<!-- Visual Identity Section -->
<div class="mb-5">
<h6 class="fw-bold text-primary mb-3 border-bottom pb-2">
@ -9421,6 +9483,43 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System';
</button>
</div>
</form>
<script>
document.addEventListener('DOMContentLoaded', function () {
var nameInput = document.getElementById('license-app-name');
var slugInput = document.getElementById('license-app-slug');
var suggestButton = document.getElementById('suggest-license-slug');
if (!nameInput || !slugInput || !suggestButton) {
return;
}
var slugWasExplicit = slugInput.dataset.explicit === '1';
var slugify = function (value) {
return String(value || '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
};
nameInput.addEventListener('input', function () {
if (!slugWasExplicit || slugInput.value.trim() === '') {
slugInput.value = slugify(nameInput.value);
}
});
slugInput.addEventListener('input', function () {
slugWasExplicit = slugInput.value.trim() !== '';
slugInput.dataset.explicit = slugWasExplicit ? '1' : '0';
});
suggestButton.addEventListener('click', function () {
slugInput.value = slugify(nameInput.value);
slugWasExplicit = slugInput.value.trim() !== '';
slugInput.dataset.explicit = slugWasExplicit ? '1' : '0';
slugInput.focus();
});
});
</script>
</div>
</div>
</div>

View File

@ -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(),
];
}
}