diff --git a/source/packages/com_mokosuiteclient/admin/sql/install.mysql.sql b/source/packages/com_mokosuiteclient/admin/sql/install.mysql.sql index 75b55c91..51e2ce1f 100644 --- a/source/packages/com_mokosuiteclient/admin/sql/install.mysql.sql +++ b/source/packages/com_mokosuiteclient/admin/sql/install.mysql.sql @@ -215,3 +215,15 @@ INSERT IGNORE INTO `#__mokosuiteclient_retention_policies` (`id`, `content_type` (4, 'inactive_users', 730, 'anonymize', 0, 'Anonymize users inactive for 2 years (disabled by default)'), (5, 'closed_tickets', 365, 'anonymize', 0, 'Anonymize closed tickets older than 1 year (disabled by default)'); + + +-- ============================================================ +-- License Cache — stores MokoGitea validation results +-- ============================================================ +CREATE TABLE IF NOT EXISTS `#__mokosuite_license_cache` ( + `dlid_hash` CHAR(64) NOT NULL COMMENT 'SHA-256 of DLID (never store raw DLID)', + `response_data` TEXT NOT NULL COMMENT 'JSON validation response from MokoGitea', + `checked_at` DATETIME NOT NULL, + PRIMARY KEY (`dlid_hash`), + KEY `idx_checked` (`checked_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/source/packages/plg_system_mokosuiteclient/Helper/LicenseValidator.php b/source/packages/plg_system_mokosuiteclient/Helper/LicenseValidator.php new file mode 100644 index 00000000..e27deadb --- /dev/null +++ b/source/packages/plg_system_mokosuiteclient/Helper/LicenseValidator.php @@ -0,0 +1,367 @@ +get(DatabaseInterface::class); + $dlid = self::getDlid(); + + if (!$dlid) { + return self::$cachedLicense = (object) [ + 'valid' => false, + 'status' => 'no_dlid', + 'message' => 'No license key configured', + 'entitlements'=> [], + ]; + } + + // Check DB cache first + if (!$forceRefresh) { + $cached = self::getCachedResult($db, $dlid); + if ($cached) { + return self::$cachedLicense = $cached; + } + } + + // Call MokoGitea API + $result = self::callGiteaApi($dlid); + + // Cache the result + self::cacheResult($db, $dlid, $result); + + return self::$cachedLicense = $result; + } + + /** + * Check if the current license includes entitlement for a specific extension. + * + * @param string $extension Extension element name (e.g., 'com_mokosuite_crm', 'com_mokosuiterestaurant') + * @return bool + */ + public static function isEntitled(string $extension): bool + { + $license = self::validate(); + + if (!$license->valid) return false; + + // Map extension names to repo identifiers + $repoMap = [ + 'com_mokosuite' => 'MokoSuite', + 'com_mokosuite_crm' => 'MokoSuiteCRM', + 'com_mokosuite_erp' => 'MokoSuiteERP', + 'com_mokosuitechild' => 'MokoSuiteChild', + 'com_mokosuitecreate' => 'MokoSuiteCreate', + 'com_mokosuitenpo' => 'MokoSuiteNPO', + 'com_mokosuitefield' => 'MokoSuiteField', + 'com_mokosuitepos' => 'MokoSuitePOS', + 'com_mokoshop' => 'MokoSuiteShop', + 'com_mokosuitehrm' => 'MokoSuiteHRM', + 'com_mokosuitemrp' => 'MokoSuiteMRP', + 'com_mokosuiterestaurant' => 'MokoSuiteRestaurant', + ]; + + $repo = $repoMap[$extension] ?? $extension; + $entitlements = $license->entitlements ?? []; + + // Base is always entitled if license is valid + if ($repo === 'MokoSuite') return true; + + return in_array($repo, $entitlements, true); + } + + /** + * Get the full license status for admin display. + */ + public static function getStatus(): object + { + $license = self::validate(); + + return (object) [ + 'valid' => $license->valid ?? false, + 'status' => $license->status ?? 'unknown', + 'tier' => $license->tier ?? 'none', + 'entitlements' => $license->entitlements ?? [], + 'expires_at' => $license->expires_at ?? null, + 'seats' => $license->seats ?? 0, + 'seats_used' => $license->seats_used ?? 0, + 'days_remaining'=> self::getDaysRemaining($license), + 'in_grace' => self::isInGracePeriod($license), + 'gitea_url' => self::getGiteaUrl(), + 'dlid_configured' => (bool) self::getDlid(), + ]; + } + + /** + * Get available seat count. + */ + public static function getAvailableSeats(): int + { + $license = self::validate(); + $total = (int) ($license->seats ?? 0); + $used = (int) ($license->seats_used ?? 0); + + if ($total === 0) return PHP_INT_MAX; // Unlimited seats + + return max(0, $total - $used); + } + + /** + * Report a heartbeat to MokoGitea (active installation check). + * Called by task scheduler daily. + */ + public static function heartbeat(): object + { + $dlid = self::getDlid(); + if (!$dlid) return (object) ['success' => false, 'error' => 'No DLID']; + + $giteaUrl = self::getGiteaUrl(); + $siteUrl = \Joomla\CMS\Uri\Uri::root(); + $joomlaVersion = (new \Joomla\CMS\Version())->getShortVersion(); + + // Count installed suite modules + $db = Factory::getContainer()->get(DatabaseInterface::class); + $db->setQuery($db->getQuery(true) + ->select('element') + ->from('#__extensions') + ->where($db->quoteName('element') . ' LIKE ' . $db->quote('com_mokosuite%')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component')) + ->where($db->quoteName('enabled') . ' = 1')); + $installedModules = $db->loadColumn() ?: []; + + $response = self::httpPost($giteaUrl . '/api/v1/licenses/heartbeat', [ + 'dlid' => $dlid, + 'site_url' => $siteUrl, + 'joomla_version' => $joomlaVersion, + 'installed_modules' => $installedModules, + 'php_version' => PHP_VERSION, + ]); + + return $response; + } + + // ── Private methods ───────────────────────────────── + + /** + * Get the configured DLID from component params. + */ + private static function getDlid(): string + { + try { + $params = Factory::getApplication()->getParams('com_mokosuite'); + return trim($params->get('dlid', '')); + } catch (\Throwable $e) { + // Component not installed or params not available + try { + $db = Factory::getContainer()->get(DatabaseInterface::class); + $db->setQuery($db->getQuery(true) + ->select('params') + ->from('#__extensions') + ->where($db->quoteName('element') . ' = ' . $db->quote('com_mokosuite')) + ->where($db->quoteName('type') . ' = ' . $db->quote('component'))); + $paramsJson = $db->loadResult(); + $params = json_decode($paramsJson ?: '{}', false); + return trim($params->dlid ?? ''); + } catch (\Throwable $e2) { + return ''; + } + } + } + + /** + * Get the MokoGitea server URL from config. + */ + private static function getGiteaUrl(): string + { + try { + $params = Factory::getApplication()->getParams('com_mokosuite'); + return rtrim($params->get('gitea_url', self::DEFAULT_GITEA_URL), '/'); + } catch (\Throwable $e) { + return self::DEFAULT_GITEA_URL; + } + } + + /** + * Call MokoGitea license validation API. + */ + private static function callGiteaApi(string $dlid): object + { + $giteaUrl = self::getGiteaUrl(); + + $response = self::httpGet($giteaUrl . '/api/v1/licenses/validate?dlid=' . urlencode($dlid)); + + if (isset($response->valid)) { + return (object) [ + 'valid' => (bool) $response->valid, + 'status' => $response->status ?? 'unknown', + 'tier' => $response->tier ?? '', + 'entitlements' => $response->entitlements ?? $response->repo_scope ?? [], + 'expires_at' => $response->expires_at ?? null, + 'seats' => (int) ($response->seats ?? 0), + 'seats_used' => (int) ($response->seats_used ?? 0), + 'message' => $response->message ?? '', + ]; + } + + // API error — use cached result if available, otherwise fail gracefully + return (object) [ + 'valid' => false, + 'status' => 'api_error', + 'message' => $response->error ?? 'Could not reach license server', + 'entitlements' => [], + ]; + } + + /** + * Get cached validation result from database. + */ + private static function getCachedResult(DatabaseInterface $db, string $dlid): ?object + { + $dlidHash = hash('sha256', $dlid); + + try { + $db->setQuery($db->getQuery(true) + ->select('response_data, checked_at') + ->from('#__mokosuite_license_cache') + ->where($db->quoteName('dlid_hash') . ' = ' . $db->quote($dlidHash)) + ->where('checked_at > DATE_SUB(NOW(), INTERVAL ' . (int) self::CACHE_TTL . ' SECOND)')); + $cached = $db->loadObject(); + + if ($cached && $cached->response_data) { + $data = json_decode($cached->response_data, false); + if ($data) return $data; + } + } catch (\Throwable $e) { + // Table may not exist yet — that's fine + } + + return null; + } + + /** + * Cache validation result in database. + */ + private static function cacheResult(DatabaseInterface $db, string $dlid, object $result): void + { + $dlidHash = hash('sha256', $dlid); + + try { + // Upsert + $db->setQuery('REPLACE INTO #__mokosuite_license_cache (dlid_hash, response_data, checked_at) VALUES (' + . $db->quote($dlidHash) . ', ' + . $db->quote(json_encode($result)) . ', ' + . $db->quote(Factory::getDate()->toSql()) . ')'); + $db->execute(); + } catch (\Throwable $e) { + // Cache table may not exist — non-fatal + } + } + + /** + * Calculate days remaining on license. + */ + private static function getDaysRemaining(object $license): ?int + { + if (empty($license->expires_at)) return null; + + $now = new \DateTime('today'); + $expiry = new \DateTime($license->expires_at); + $diff = (int) $now->diff($expiry)->format('%r%a'); + + return $diff; + } + + /** + * Check if license is in grace period (expired but within grace window). + */ + private static function isInGracePeriod(object $license): bool + { + $days = self::getDaysRemaining($license); + if ($days === null || $days >= 0) return false; + + $graceDays = self::DEFAULT_GRACE_DAYS; + try { + $graceDays = (int) Factory::getApplication()->getParams('com_mokosuite')->get('license_grace_days', self::DEFAULT_GRACE_DAYS); + } catch (\Throwable $e) {} + + return abs($days) <= $graceDays; + } + + /** + * HTTP GET helper. + */ + private static function httpGet(string $url): object + { + $response = file_get_contents($url, false, stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => 'Accept: application/json', + 'ignore_errors' => true, + 'timeout' => 10, + ], + ])); + + return json_decode($response ?: '{}', false) ?: (object) ['error' => 'No response']; + } + + /** + * HTTP POST helper. + */ + private static function httpPost(string $url, array $data): object + { + $response = file_get_contents($url, false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\nAccept: application/json", + 'ignore_errors' => true, + 'timeout' => 10, + 'content' => json_encode($data), + ], + ])); + + return json_decode($response ?: '{}', false) ?: (object) ['error' => 'No response']; + } +} diff --git a/source/packages/plg_task_mokosuiteclient_tickets/src/Extension/TicketAutomation.php b/source/packages/plg_task_mokosuiteclient_tickets/src/Extension/TicketAutomation.php index 66ea7c08..16bc8d6c 100644 --- a/source/packages/plg_task_mokosuiteclient_tickets/src/Extension/TicketAutomation.php +++ b/source/packages/plg_task_mokosuiteclient_tickets/src/Extension/TicketAutomation.php @@ -38,6 +38,14 @@ class TicketAutomation extends CMSPlugin implements SubscriberInterface 'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_TICKETS_AUTOCLOSE', 'method' => 'runAutoClose', ], + 'mokosuiteclient.license.validate' => [ + 'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_LICENSE_VALIDATE', + 'method' => 'runLicenseValidation', + ], + 'mokosuiteclient.license.heartbeat' => [ + 'langConstPrefix' => 'PLG_TASK_MOKOSUITECLIENT_LICENSE_HEARTBEAT', + 'method' => 'runLicenseHeartbeat', + ], ]; protected $autoloadLanguage = true; @@ -310,4 +318,50 @@ class TicketAutomation extends CMSPlugin implements SubscriberInterface return trim($textBody); } + + /** + * Daily license revalidation — refresh cached license status from MokoGitea. + * Recommended schedule: daily at 3:00 AM. + */ + private function runLicenseValidation(ExecuteTaskEvent $event): int + { + try { + $result = \Moko\Plugin\System\MokoSuiteClient\Helper\LicenseValidator::validate(true); + + $status = $result->valid ? 'valid' : ($result->status ?? 'invalid'); + $tier = $result->tier ?? 'none'; + $entitlements = count($result->entitlements ?? []); + + $this->logTask(sprintf( + 'License validation: status=%s tier=%s entitlements=%d', + $status, $tier, $entitlements + )); + + if (!$result->valid) { + Log::add('License validation failed: ' . ($result->message ?? 'unknown'), Log::WARNING, 'mokosuite.license'); + } + + return Status::OK; + } catch (\Throwable $e) { + $this->logTask('License validation error: ' . $e->getMessage()); + return Status::KNOCKOUT; + } + } + + /** + * Daily heartbeat — report active installation to MokoGitea. + * Recommended schedule: daily at 4:00 AM. + */ + private function runLicenseHeartbeat(ExecuteTaskEvent $event): int + { + try { + $result = \Moko\Plugin\System\MokoSuiteClient\Helper\LicenseValidator::heartbeat(); + + $this->logTask('License heartbeat: ' . ($result->success ?? false ? 'sent' : ($result->error ?? 'failed'))); + return Status::OK; + } catch (\Throwable $e) { + $this->logTask('License heartbeat error: ' . $e->getMessage()); + return Status::KNOCKOUT; + } + } }