2026-01-22 15:51:05 +01:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace Magdev\WcLicensedProductClient;
|
|
|
|
|
|
|
|
|
|
use Magdev\WcLicensedProductClient\Dto\ActivationResult;
|
|
|
|
|
use Magdev\WcLicensedProductClient\Dto\LicenseInfo;
|
|
|
|
|
use Magdev\WcLicensedProductClient\Dto\LicenseStatus;
|
|
|
|
|
use Magdev\WcLicensedProductClient\Exception\LicenseException;
|
|
|
|
|
use Psr\Cache\CacheItemPoolInterface;
|
|
|
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
|
use Psr\Log\NullLogger;
|
|
|
|
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
|
|
|
|
|
|
|
|
|
final class LicenseClient implements LicenseClientInterface
|
|
|
|
|
{
|
|
|
|
|
private const API_PATH = '/wp-json/wc-licensed-product/v1';
|
|
|
|
|
private const CACHE_TTL = 300; // 5 minutes
|
|
|
|
|
|
2026-01-24 14:31:13 +01:00
|
|
|
/** @var string[] Private IPv4 ranges (CIDR notation) */
|
|
|
|
|
private const PRIVATE_IP_RANGES = [
|
|
|
|
|
'10.0.0.0/8',
|
|
|
|
|
'172.16.0.0/12',
|
|
|
|
|
'192.168.0.0/16',
|
|
|
|
|
'127.0.0.0/8',
|
|
|
|
|
'169.254.0.0/16',
|
|
|
|
|
'0.0.0.0/8',
|
|
|
|
|
];
|
|
|
|
|
|
2026-01-22 15:51:05 +01:00
|
|
|
private readonly LoggerInterface $logger;
|
|
|
|
|
|
|
|
|
|
public function __construct(
|
|
|
|
|
private readonly HttpClientInterface $httpClient,
|
|
|
|
|
private readonly string $baseUrl,
|
|
|
|
|
?LoggerInterface $logger = null,
|
|
|
|
|
private readonly ?CacheItemPoolInterface $cache = null,
|
|
|
|
|
private readonly int $cacheTtl = self::CACHE_TTL,
|
2026-01-24 14:31:13 +01:00
|
|
|
bool $allowInsecureHttp = false,
|
2026-01-22 15:51:05 +01:00
|
|
|
) {
|
|
|
|
|
$this->logger = $logger ?? new NullLogger();
|
2026-01-24 14:31:13 +01:00
|
|
|
$this->validateBaseUrl($baseUrl, $allowInsecureHttp);
|
2026-01-22 15:51:05 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function validate(string $licenseKey, string $domain): LicenseInfo
|
|
|
|
|
{
|
|
|
|
|
$cacheKey = $this->buildCacheKey('validate', $licenseKey, $domain);
|
|
|
|
|
|
|
|
|
|
if ($this->cache !== null) {
|
|
|
|
|
$item = $this->cache->getItem($cacheKey);
|
|
|
|
|
if ($item->isHit()) {
|
|
|
|
|
$this->logger->debug('License validation cache hit', [
|
|
|
|
|
'domain' => $domain,
|
|
|
|
|
]);
|
|
|
|
|
return $item->get();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->logger->info('Validating license', [
|
|
|
|
|
'domain' => $domain,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response = $this->request('validate', [
|
|
|
|
|
'license_key' => $licenseKey,
|
|
|
|
|
'domain' => $domain,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = LicenseInfo::fromArray($response['license']);
|
|
|
|
|
|
|
|
|
|
if ($this->cache !== null) {
|
|
|
|
|
$item = $this->cache->getItem($cacheKey);
|
|
|
|
|
$item->set($result);
|
|
|
|
|
$item->expiresAfter($this->cacheTtl);
|
|
|
|
|
$this->cache->save($item);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->logger->info('License validated successfully', [
|
|
|
|
|
'domain' => $domain,
|
|
|
|
|
'product_id' => $result->productId,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return $result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function status(string $licenseKey): LicenseStatus
|
|
|
|
|
{
|
|
|
|
|
$cacheKey = $this->buildCacheKey('status', $licenseKey);
|
|
|
|
|
|
|
|
|
|
if ($this->cache !== null) {
|
|
|
|
|
$item = $this->cache->getItem($cacheKey);
|
|
|
|
|
if ($item->isHit()) {
|
|
|
|
|
$this->logger->debug('License status cache hit');
|
|
|
|
|
return $item->get();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->logger->info('Checking license status');
|
|
|
|
|
|
|
|
|
|
$response = $this->request('status', [
|
|
|
|
|
'license_key' => $licenseKey,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = LicenseStatus::fromArray($response);
|
|
|
|
|
|
|
|
|
|
if ($this->cache !== null) {
|
|
|
|
|
$item = $this->cache->getItem($cacheKey);
|
|
|
|
|
$item->set($result);
|
|
|
|
|
$item->expiresAfter($this->cacheTtl);
|
|
|
|
|
$this->cache->save($item);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->logger->info('License status retrieved', [
|
|
|
|
|
'status' => $result->status->value,
|
|
|
|
|
'valid' => $result->valid,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return $result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function activate(string $licenseKey, string $domain): ActivationResult
|
|
|
|
|
{
|
|
|
|
|
$this->logger->info('Activating license', [
|
|
|
|
|
'domain' => $domain,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response = $this->request('activate', [
|
|
|
|
|
'license_key' => $licenseKey,
|
|
|
|
|
'domain' => $domain,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = ActivationResult::fromArray($response);
|
|
|
|
|
|
|
|
|
|
// Invalidate related cache entries after activation
|
|
|
|
|
if ($this->cache !== null) {
|
|
|
|
|
$this->cache->deleteItem($this->buildCacheKey('validate', $licenseKey, $domain));
|
|
|
|
|
$this->cache->deleteItem($this->buildCacheKey('status', $licenseKey));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->logger->info('License activation completed', [
|
|
|
|
|
'domain' => $domain,
|
|
|
|
|
'success' => $result->success,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return $result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @throws LicenseException
|
|
|
|
|
*/
|
|
|
|
|
private function request(string $endpoint, array $payload): array
|
|
|
|
|
{
|
|
|
|
|
$url = rtrim($this->baseUrl, '/') . self::API_PATH . '/' . $endpoint;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$response = $this->httpClient->request('POST', $url, [
|
|
|
|
|
'json' => $payload,
|
|
|
|
|
'headers' => [
|
|
|
|
|
'Accept' => 'application/json',
|
|
|
|
|
'Content-Type' => 'application/json',
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$statusCode = $response->getStatusCode();
|
|
|
|
|
$data = $response->toArray(false);
|
|
|
|
|
|
|
|
|
|
if ($statusCode >= 400) {
|
|
|
|
|
$this->logger->warning('License API error response', [
|
|
|
|
|
'endpoint' => $endpoint,
|
|
|
|
|
'status_code' => $statusCode,
|
|
|
|
|
'error' => $data['error'] ?? 'unknown',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
throw LicenseException::fromApiResponse($data, $statusCode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $data;
|
|
|
|
|
} catch (LicenseException $e) {
|
|
|
|
|
throw $e;
|
|
|
|
|
} catch (\Throwable $e) {
|
2026-01-24 14:31:13 +01:00
|
|
|
// Log full error for debugging but sanitize for user-facing message
|
2026-01-22 15:51:05 +01:00
|
|
|
$this->logger->error('License API request failed', [
|
|
|
|
|
'endpoint' => $endpoint,
|
2026-01-24 14:31:13 +01:00
|
|
|
'exception_class' => $e::class,
|
|
|
|
|
'error_code' => $e->getCode(),
|
2026-01-22 15:51:05 +01:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
throw new LicenseException(
|
2026-01-24 14:31:13 +01:00
|
|
|
'Failed to communicate with license server',
|
2026-01-22 15:51:05 +01:00
|
|
|
null,
|
|
|
|
|
0,
|
|
|
|
|
$e
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function buildCacheKey(string $operation, string $licenseKey, ?string $domain = null): string
|
|
|
|
|
{
|
|
|
|
|
$key = 'wc_license_' . $operation . '_' . hash('sha256', $licenseKey);
|
|
|
|
|
if ($domain !== null) {
|
|
|
|
|
$key .= '_' . hash('sha256', $domain);
|
|
|
|
|
}
|
|
|
|
|
return $key;
|
|
|
|
|
}
|
2026-01-24 14:31:13 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate the base URL to prevent SSRF attacks.
|
|
|
|
|
*
|
|
|
|
|
* @throws \InvalidArgumentException If the URL is invalid or potentially dangerous
|
|
|
|
|
*/
|
|
|
|
|
private function validateBaseUrl(string $url, bool $allowInsecureHttp): void
|
|
|
|
|
{
|
|
|
|
|
if ($url === '') {
|
|
|
|
|
throw new \InvalidArgumentException('Base URL cannot be empty');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$parsed = parse_url($url);
|
|
|
|
|
if ($parsed === false || !isset($parsed['scheme'], $parsed['host'])) {
|
|
|
|
|
throw new \InvalidArgumentException('Invalid base URL format');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$scheme = strtolower($parsed['scheme']);
|
|
|
|
|
$host = strtolower($parsed['host']);
|
|
|
|
|
|
|
|
|
|
// Require HTTPS unless explicitly allowed for localhost
|
|
|
|
|
if ($scheme !== 'https') {
|
|
|
|
|
if ($scheme !== 'http') {
|
|
|
|
|
throw new \InvalidArgumentException('Base URL must use HTTP or HTTPS scheme');
|
|
|
|
|
}
|
|
|
|
|
$isLocalhost = $host === 'localhost' || $host === '127.0.0.1';
|
|
|
|
|
if (!$allowInsecureHttp && !$isLocalhost) {
|
|
|
|
|
throw new \InvalidArgumentException(
|
|
|
|
|
'Base URL must use HTTPS for non-localhost hosts. ' .
|
|
|
|
|
'Set allowInsecureHttp=true to allow HTTP (not recommended for production).'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolve hostname and check for private IPs
|
|
|
|
|
$ip = gethostbyname($host);
|
|
|
|
|
if ($ip !== $host && $this->isPrivateIp($ip)) {
|
|
|
|
|
throw new \InvalidArgumentException(
|
|
|
|
|
'Base URL resolves to a private IP address, which is not allowed'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if an IP address is in a private range.
|
|
|
|
|
*/
|
|
|
|
|
private function isPrivateIp(string $ip): bool
|
|
|
|
|
{
|
|
|
|
|
$ipLong = ip2long($ip);
|
|
|
|
|
if ($ipLong === false) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach (self::PRIVATE_IP_RANGES as $range) {
|
|
|
|
|
[$subnet, $bits] = explode('/', $range);
|
|
|
|
|
$subnetLong = ip2long($subnet);
|
|
|
|
|
$mask = -1 << (32 - (int) $bits);
|
|
|
|
|
|
|
|
|
|
if (($ipLong & $mask) === ($subnetLong & $mask)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-01-22 15:51:05 +01:00
|
|
|
}
|