531 lines
12 KiB
PHP
531 lines
12 KiB
PHP
<?php
|
||
|
||
namespace Kirby\Cms;
|
||
|
||
use IntlDateFormatter;
|
||
use Kirby\Data\Json;
|
||
use Kirby\Exception\Exception;
|
||
use Kirby\Exception\InvalidArgumentException;
|
||
use Kirby\Exception\LogicException;
|
||
use Kirby\Filesystem\F;
|
||
use Kirby\Http\Remote;
|
||
use Kirby\Toolkit\Str;
|
||
use Kirby\Toolkit\V;
|
||
use Throwable;
|
||
|
||
/**
|
||
* @package Kirby Cms
|
||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||
* @link https://getkirby.com
|
||
* @copyright Bastian Allgeier
|
||
* @license https://getkirby.com/license
|
||
*/
|
||
class License
|
||
{
|
||
public const HISTORY = [
|
||
'3' => '2019-02-05',
|
||
'4' => '2023-11-28'
|
||
];
|
||
|
||
protected const SALT = 'kwAHMLyLPBnHEskzH9pPbJsBxQhKXZnX';
|
||
|
||
// cache
|
||
protected LicenseStatus $status;
|
||
protected LicenseType $type;
|
||
|
||
public function __construct(
|
||
protected string|null $activation = null,
|
||
protected string|null $code = null,
|
||
protected string|null $domain = null,
|
||
protected string|null $email = null,
|
||
protected string|null $order = null,
|
||
protected string|null $date = null,
|
||
protected string|null $signature = null,
|
||
) {
|
||
// normalize arguments
|
||
$this->code = $this->code !== null ? trim($this->code) : null;
|
||
$this->email = $this->email !== null ? $this->normalizeEmail($this->email) : null;
|
||
}
|
||
|
||
/**
|
||
* Returns the activation date if available
|
||
*/
|
||
public function activation(
|
||
string|IntlDateFormatter|null $format = null,
|
||
string|null $handler = null
|
||
): int|string|null {
|
||
return $this->activation !== null ? Str::date(strtotime($this->activation), $format, $handler) : null;
|
||
}
|
||
|
||
/**
|
||
* Returns the license code if available
|
||
*/
|
||
public function code(bool $obfuscated = false): string|null
|
||
{
|
||
if ($this->code !== null && $obfuscated === true) {
|
||
return Str::substr($this->code, 0, 10) . str_repeat('X', 22);
|
||
}
|
||
|
||
return $this->code;
|
||
}
|
||
|
||
/**
|
||
* Content for the license file
|
||
*/
|
||
public function content(): array
|
||
{
|
||
return [
|
||
'activation' => $this->activation,
|
||
'code' => $this->code,
|
||
'date' => $this->date,
|
||
'domain' => $this->domain,
|
||
'email' => $this->email,
|
||
'order' => $this->order,
|
||
'signature' => $this->signature,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Returns the purchase date if available
|
||
*/
|
||
public function date(
|
||
string|IntlDateFormatter|null $format = null,
|
||
string|null $handler = null
|
||
): int|string|null {
|
||
return $this->date !== null ? Str::date(strtotime($this->date), $format, $handler) : null;
|
||
}
|
||
|
||
/**
|
||
* Returns the activation domain if available
|
||
*/
|
||
public function domain(): string|null
|
||
{
|
||
return $this->domain;
|
||
}
|
||
|
||
/**
|
||
* Returns the activation email if available
|
||
*/
|
||
public function email(): string|null
|
||
{
|
||
return $this->email;
|
||
}
|
||
|
||
/**
|
||
* Validates the email address of the license
|
||
*/
|
||
public function hasValidEmailAddress(): bool
|
||
{
|
||
return V::email($this->email) === true;
|
||
}
|
||
|
||
/**
|
||
* Hub address
|
||
*/
|
||
public static function hub(): string
|
||
{
|
||
return App::instance()->option('hub', 'https://hub.getkirby.com');
|
||
}
|
||
|
||
/**
|
||
* Checks for all required components of a valid license
|
||
*/
|
||
public function isComplete(): bool
|
||
{
|
||
if (
|
||
$this->code !== null &&
|
||
$this->date !== null &&
|
||
$this->domain !== null &&
|
||
$this->email !== null &&
|
||
$this->order !== null &&
|
||
$this->signature !== null &&
|
||
$this->hasValidEmailAddress() === true &&
|
||
$this->type() !== LicenseType::Invalid
|
||
) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* The license is still valid for the currently
|
||
* installed version, but it passed the 3 year period.
|
||
*/
|
||
public function isInactive(): bool
|
||
{
|
||
return $this->renewal() < time();
|
||
}
|
||
|
||
/**
|
||
* Checks for licenses beyond their 3 year period
|
||
*/
|
||
public function isLegacy(): bool
|
||
{
|
||
if ($this->type() === LicenseType::Legacy) {
|
||
return true;
|
||
}
|
||
|
||
// without an activation date, the license
|
||
// renewal cannot be evaluated and the license
|
||
// has to be marked as expired
|
||
if ($this->activation === null) {
|
||
return true;
|
||
}
|
||
|
||
// get release date of current major version
|
||
$major = Str::before(App::instance()->version(), '.');
|
||
$release = strtotime(static::HISTORY[$major] ?? '');
|
||
|
||
// if there's no matching version in the history
|
||
// rather throw an exception to avoid further issues
|
||
// @codeCoverageIgnoreStart
|
||
if ($release === false) {
|
||
throw new InvalidArgumentException('The version for your license could not be found');
|
||
}
|
||
// @codeCoverageIgnoreEnd
|
||
|
||
// If the renewal date is older than the version launch
|
||
// date, the license is expired
|
||
return $this->renewal() < $release;
|
||
}
|
||
|
||
/**
|
||
* Runs multiple checks to find out if the license is
|
||
* installed and verifiable
|
||
*/
|
||
public function isMissing(): bool
|
||
{
|
||
return
|
||
$this->isComplete() === false ||
|
||
$this->isOnCorrectDomain() === false ||
|
||
$this->isSigned() === false;
|
||
}
|
||
|
||
/**
|
||
* Checks if the license is on the correct domain
|
||
*/
|
||
public function isOnCorrectDomain(): bool
|
||
{
|
||
if ($this->domain === null) {
|
||
return false;
|
||
}
|
||
|
||
// compare domains
|
||
if ($this->normalizeDomain(App::instance()->system()->indexUrl()) !== $this->normalizeDomain($this->domain)) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Compares the signature with all ingredients
|
||
*/
|
||
public function isSigned(): bool
|
||
{
|
||
if ($this->signature === null) {
|
||
return false;
|
||
}
|
||
|
||
// get the public key
|
||
$pubKey = F::read(App::instance()->root('kirby') . '/kirby.pub');
|
||
|
||
// verify the license signature
|
||
$data = json_encode($this->signatureData());
|
||
$signature = hex2bin($this->signature);
|
||
|
||
return openssl_verify($data, $signature, $pubKey, 'RSA-SHA256') === 1;
|
||
}
|
||
|
||
/**
|
||
* Returns a reliable label for the license type
|
||
*/
|
||
public function label(): string
|
||
{
|
||
if ($this->status() === LicenseStatus::Missing) {
|
||
return LicenseType::Invalid->label();
|
||
}
|
||
|
||
return $this->type()->label();
|
||
}
|
||
|
||
/**
|
||
* Prepares the email address to be make sure it
|
||
* does not have trailing spaces and is lowercase.
|
||
*/
|
||
protected function normalizeEmail(string $email): string
|
||
{
|
||
return Str::lower(trim($email));
|
||
}
|
||
|
||
/**
|
||
* Prepares the domain to be comparable
|
||
*/
|
||
protected function normalizeDomain(string $domain): string
|
||
{
|
||
// remove common "testing" subdomains as well as www.
|
||
// to ensure that installations of the same site have
|
||
// the same license URL; only for installations at /,
|
||
// subdirectory installations are difficult to normalize
|
||
if (Str::contains($domain, '/') === false) {
|
||
if (Str::startsWith($domain, 'www.')) {
|
||
return substr($domain, 4);
|
||
}
|
||
|
||
if (Str::startsWith($domain, 'dev.')) {
|
||
return substr($domain, 4);
|
||
}
|
||
|
||
if (Str::startsWith($domain, 'test.')) {
|
||
return substr($domain, 5);
|
||
}
|
||
|
||
if (Str::startsWith($domain, 'staging.')) {
|
||
return substr($domain, 8);
|
||
}
|
||
}
|
||
|
||
return $domain;
|
||
}
|
||
|
||
/**
|
||
* Returns the order id if available
|
||
*/
|
||
public function order(): string|null
|
||
{
|
||
return $this->order;
|
||
}
|
||
|
||
/**
|
||
* Support the old license file dataset
|
||
* from older licenses
|
||
*/
|
||
public static function polyfill(array $license): array
|
||
{
|
||
return [
|
||
'activation' => $license['activation'] ?? null,
|
||
'code' => $license['code'] ?? $license['license'] ?? null,
|
||
'date' => $license['date'] ?? null,
|
||
'domain' => $license['domain'] ?? null,
|
||
'email' => $license['email'] ?? null,
|
||
'order' => $license['order'] ?? null,
|
||
'signature' => $license['signature'] ?? null,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Reads the license file in the config folder
|
||
* and creates a new license instance for it.
|
||
*/
|
||
public static function read(): static
|
||
{
|
||
try {
|
||
$license = Json::read(App::instance()->root('license'));
|
||
} catch (Throwable) {
|
||
return new static();
|
||
}
|
||
|
||
return new static(...static::polyfill($license));
|
||
}
|
||
|
||
/**
|
||
* Sends a request to the hub to register the license
|
||
*/
|
||
public function register(): static
|
||
{
|
||
if ($this->type() === LicenseType::Invalid) {
|
||
throw new InvalidArgumentException(['key' => 'license.format']);
|
||
}
|
||
|
||
if ($this->hasValidEmailAddress() === false) {
|
||
throw new InvalidArgumentException(['key' => 'license.email']);
|
||
}
|
||
|
||
if ($this->domain === null) {
|
||
throw new InvalidArgumentException(['key' => 'license.domain']);
|
||
}
|
||
|
||
// @codeCoverageIgnoreStart
|
||
$response = $this->request('register', [
|
||
'license' => $this->code,
|
||
'email' => $this->email,
|
||
'domain' => $this->domain
|
||
]);
|
||
|
||
return $this->update($response);
|
||
// @codeCoverageIgnoreEnd
|
||
}
|
||
|
||
/**
|
||
* Returns the renewal date
|
||
*/
|
||
public function renewal(
|
||
string|IntlDateFormatter|null $format = null,
|
||
string|null $handler = null
|
||
): int|string|null {
|
||
if ($this->activation === null) {
|
||
return null;
|
||
}
|
||
|
||
$time = strtotime('+3 years', $this->activation());
|
||
return Str::date($time, $format, $handler);
|
||
}
|
||
|
||
/**
|
||
* Sends a hub request
|
||
*/
|
||
public function request(string $path, array $data): array
|
||
{
|
||
// @codeCoverageIgnoreStart
|
||
$response = Remote::get(static::hub() . '/' . $path, [
|
||
'data' => $data
|
||
]);
|
||
|
||
// handle request errors
|
||
if ($response->code() !== 200) {
|
||
$message = $response->json()['message'] ?? 'The request failed';
|
||
|
||
throw new LogicException($message, $response->code());
|
||
}
|
||
|
||
return $response->json();
|
||
// @codeCoverageIgnoreEnd
|
||
}
|
||
|
||
/**
|
||
* Saves the license in the config folder
|
||
*/
|
||
public function save(): bool
|
||
{
|
||
if ($this->status()->activatable() !== true) {
|
||
throw new InvalidArgumentException([
|
||
'key' => 'license.verification'
|
||
]);
|
||
}
|
||
|
||
// where to store the license file
|
||
$file = App::instance()->root('license');
|
||
|
||
// save the license information
|
||
return Json::write($file, $this->content());
|
||
}
|
||
|
||
/**
|
||
* Returns the signature if available
|
||
*/
|
||
public function signature(): string|null
|
||
{
|
||
return $this->signature;
|
||
}
|
||
|
||
/**
|
||
* Creates the signature data array to compare
|
||
* with the signature in ::isSigned
|
||
*/
|
||
public function signatureData(): array
|
||
{
|
||
if ($this->type() === LicenseType::Legacy) {
|
||
return [
|
||
'license' => $this->code,
|
||
'order' => $this->order,
|
||
'email' => hash('sha256', $this->email . static::SALT),
|
||
'domain' => $this->domain,
|
||
'date' => $this->date,
|
||
];
|
||
}
|
||
|
||
return [
|
||
'activation' => $this->activation,
|
||
'code' => $this->code,
|
||
'date' => $this->date,
|
||
'domain' => $this->domain,
|
||
'email' => hash('sha256', $this->email . static::SALT),
|
||
'order' => $this->order,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Returns the license status as string
|
||
* This is used to build the proper UI elements
|
||
* for the license activation
|
||
*/
|
||
public function status(): LicenseStatus
|
||
{
|
||
return $this->status ??= match (true) {
|
||
$this->isMissing() === true => LicenseStatus::Missing,
|
||
$this->isLegacy() === true => LicenseStatus::Legacy,
|
||
$this->isInactive() === true => LicenseStatus::Inactive,
|
||
default => LicenseStatus::Active
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Detects the license type if the license key is available
|
||
*/
|
||
public function type(): LicenseType
|
||
{
|
||
return $this->type ??= LicenseType::detect($this->code);
|
||
}
|
||
|
||
/**
|
||
* Updates the license file
|
||
*/
|
||
public function update(array $data): static
|
||
{
|
||
// decode the response
|
||
$data = static::polyfill($data);
|
||
|
||
$this->activation = $data['activation'];
|
||
$this->code = $data['code'];
|
||
$this->date = $data['date'];
|
||
$this->order = $data['order'];
|
||
$this->signature = $data['signature'];
|
||
|
||
// clear the caches
|
||
unset($this->status, $this->type);
|
||
|
||
// save the new state of the license
|
||
$this->save();
|
||
|
||
return $this;
|
||
}
|
||
|
||
/**
|
||
* Sends an upgrade request to the hub in order
|
||
* to either redirect to the upgrade form or
|
||
* sync the new license state
|
||
*
|
||
* @codeCoverageIgnore
|
||
*/
|
||
public function upgrade(): array
|
||
{
|
||
$response = $this->request('upgrade', [
|
||
'domain' => $this->domain,
|
||
'email' => $this->email,
|
||
'license' => $this->code,
|
||
]);
|
||
|
||
// the license still needs an upgrade
|
||
if (empty($response['url']) === false) {
|
||
// validate the redirect URL
|
||
if (Str::startsWith($response['url'], static::hub()) === false) {
|
||
throw new Exception('We couldn’t redirect you to the Hub');
|
||
}
|
||
|
||
return [
|
||
'status' => 'upgrade',
|
||
'url' => $response['url']
|
||
];
|
||
}
|
||
|
||
// the license has already been upgraded
|
||
// and can now be replaced
|
||
$this->update($response);
|
||
|
||
return [
|
||
'status' => 'complete',
|
||
];
|
||
}
|
||
}
|