6bb1b43195
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (push) Blocked by required conditions
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Blocked by required conditions
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 13s
Universal: Auto Version Bump / Version Bump (push) Successful in 16s
Generic: Project CI / Lint & Validate (pull_request) Successful in 16s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 25s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 42s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 46s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 50s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 41s
Generic: Project CI / Tests (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
368 lines
10 KiB
PHP
368 lines
10 KiB
PHP
<?php
|
|
/**
|
|
* @package MokoSuiteClient
|
|
* @subpackage plg_system_mokosuiteclient
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GNU General Public License version 3 or later; see LICENSE
|
|
*/
|
|
|
|
namespace Moko\Plugin\System\MokoSuiteClient\Helper;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\Database\DatabaseInterface;
|
|
|
|
/**
|
|
* MokoGitea License Validator — core DRM enforcement for the MokoSuite platform.
|
|
*
|
|
* Validates the site's DLID against MokoGitea, caches the result,
|
|
* and provides entitlement checking for all suite modules.
|
|
*
|
|
* Default Gitea server: git.mokoconsulting.tech
|
|
*
|
|
* @since 02.45.00
|
|
*/
|
|
final class LicenseValidator
|
|
{
|
|
/** @var string Default MokoGitea server address */
|
|
private const DEFAULT_GITEA_URL = 'https://git.mokoconsulting.tech';
|
|
|
|
/** @var int Cache TTL in seconds (24 hours) */
|
|
private const CACHE_TTL = 86400;
|
|
|
|
/** @var int Grace period in days after expiry before deactivation */
|
|
private const DEFAULT_GRACE_DAYS = 7;
|
|
|
|
/** @var object|null Cached license data for current request */
|
|
private static ?object $cachedLicense = null;
|
|
|
|
/**
|
|
* Validate the site's DLID against MokoGitea.
|
|
* Returns cached result if still valid; calls API if expired.
|
|
*/
|
|
public static function validate(bool $forceRefresh = false): object
|
|
{
|
|
if (self::$cachedLicense && !$forceRefresh) {
|
|
return self::$cachedLicense;
|
|
}
|
|
|
|
$db = Factory::getContainer()->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'];
|
|
}
|
|
}
|