updating activation management
This commit is contained in:
parent
18ae044a97
commit
9e51645c2f
76
central_license_manager/README.md
Normal file
76
central_license_manager/README.md
Normal 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.
|
||||
652
central_license_manager/bootstrap.php
Normal file
652
central_license_manager/bootstrap.php
Normal 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';
|
||||
}
|
||||
33
central_license_manager/config.php
Normal file
33
central_license_manager/config.php
Normal 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;
|
||||
}
|
||||
473
central_license_manager/index.php
Normal file
473
central_license_manager/index.php
Normal 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>
|
||||
147
central_license_manager/install.php
Normal file
147
central_license_manager/install.php
Normal 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>
|
||||
797
central_license_manager/manage.php
Normal file
797
central_license_manager/manage.php
Normal 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>
|
||||
99
index.php
99
index.php
@ -2902,6 +2902,10 @@ if (isset($_POST['add_hr_department'])) {
|
||||
if (isset($_POST['settings']) && is_array($_POST['settings'])) {
|
||||
$settings = $_POST['settings'];
|
||||
$settings['weight_barcode_mode'] = in_array(($settings['weight_barcode_mode'] ?? 'weight'), ['weight', 'price'], true) ? $settings['weight_barcode_mode'] : 'weight';
|
||||
$licenseAppName = trim((string)($settings['license_app_name'] ?? ''));
|
||||
$settings['license_app_name'] = $licenseAppName !== '' ? substr($licenseAppName, 0, 190) : '';
|
||||
$licenseAppSlug = trim((string)($settings['license_app_slug'] ?? ''));
|
||||
$settings['license_app_slug'] = $licenseAppSlug !== '' ? LicenseService::sanitizeAppSlug($licenseAppSlug, true) : '';
|
||||
$prefixStart = (int)($settings['weight_barcode_prefix_start'] ?? 20);
|
||||
$prefixEnd = (int)($settings['weight_barcode_prefix_end'] ?? 29);
|
||||
if ($prefixStart < 20 || $prefixStart > 29) $prefixStart = 20;
|
||||
@ -9225,6 +9229,19 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System';
|
||||
</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>
|
||||
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user