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

474 lines
20 KiB
PHP

<?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>