474 lines
20 KiB
PHP
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>
|