- New Integrity class checksums the files that implement/enforce the free/paid split (SubApps, Analytics, AnalyticsData, WhiteLabel, Shell, License itself, and the two bootstrap files) against a manifest generated at zip-build time. No manifest present (a dev checkout) is never treated as tampered. - License::is_feature_available() checks Integrity::is_tampered() before checking the actual license, so a distributed copy with the check stripped/commented out re-locks every paid feature even with an otherwise-valid license, rather than the plugin trusting whatever code happens to still be there. - bin/build-integrity-manifest.php generates the manifest against a staged zip-build copy (gitignored build artifact, same pattern as app/dist) — now a required step in the zip-build workflow. - Verified live: untampered+unlicensed stays locked, untampered+licensed unlocks, one tampered file re-locks regardless of license status (tested via a temporary mu-plugin faking an active license, kept outside any protected file so it didn't itself trip the check). This raises the bar against the realistic low-effort case (finding and deleting/inverting the one license-check line) but can't stop someone who rewrites a file's logic to never call the gate, or who edits this integrity check itself — an inherent limit of protection living in plain, editable PHP. Co-Authored-By: Claude Sonnet 5 <[email protected]>
205 lines
6.4 KiB
PHP
205 lines
6.4 KiB
PHP
<?php
|
|
namespace AuroraAdmin\Licensing;
|
|
|
|
use AuroraAdmin\Options\Settings;
|
|
|
|
defined("ABSPATH") || exit();
|
|
|
|
/**
|
|
* Single gate every paid feature funnels through, so the free/paid split
|
|
* lives in one place instead of being re-decided in each feature's code.
|
|
*
|
|
* Validates against the real license server: the "aurora-licensing" WP
|
|
* plugin running on auroraplugins.com, REST namespace
|
|
* /wp-json/aurora-licensing/v1/ (validate/activate/deactivate). Every
|
|
* result is cached in the aurora_admin_license_status option for
|
|
* STATUS_TTL, refreshed immediately (not just on expiry) whenever the
|
|
* license_key setting actually changes — see on_settings_updated().
|
|
*/
|
|
class License
|
|
{
|
|
/**
|
|
* Paid sub-apps/features, keyed by the id used to gate them, mapped to a
|
|
* human label used in upgrade prompts.
|
|
*/
|
|
const PAID_FEATURES = [
|
|
"role-editor" => "Role Editor",
|
|
"activity-log" => "Activity Log",
|
|
"database-explorer" => "Database Explorer",
|
|
"analytics" => "Analytics",
|
|
"white-label" => "White Label",
|
|
"remote-site-switcher" => "Remote Site Switcher",
|
|
];
|
|
|
|
const API_BASE = "https://auroraplugins.com/wp-json/aurora-licensing/v1/";
|
|
const STATUS_OPTION = "aurora_admin_license_status";
|
|
const STATUS_TTL = 12 * HOUR_IN_SECONDS;
|
|
|
|
public function __construct()
|
|
{
|
|
add_action("update_option_aurora_admin_settings", [self::class, "on_settings_updated"], 10, 2);
|
|
}
|
|
|
|
/**
|
|
* Fires on every save of the aurora_admin_settings option (not just the
|
|
* license field) — only acts when license_key actually changed, so
|
|
* saving an unrelated settings category never triggers a remote call.
|
|
* Deactivates the old key's activation on this domain before activating
|
|
* the new one, so switching keys doesn't leak a stale activation slot.
|
|
*/
|
|
public static function on_settings_updated($old_value, $value)
|
|
{
|
|
$old_key = is_array($old_value) ? trim((string) ($old_value["license_key"] ?? "")) : "";
|
|
$new_key = is_array($value) ? trim((string) ($value["license_key"] ?? "")) : "";
|
|
|
|
if ($old_key === $new_key) {
|
|
return;
|
|
}
|
|
|
|
if ($old_key !== "") {
|
|
self::call("deactivate", ["license_key" => $old_key, "domain" => self::domain()]);
|
|
}
|
|
|
|
if ($new_key === "") {
|
|
delete_option(self::STATUS_OPTION);
|
|
return;
|
|
}
|
|
|
|
self::activate($new_key);
|
|
}
|
|
|
|
public static function is_active()
|
|
{
|
|
$status = self::status();
|
|
return !empty($status["valid"]) && ($status["status"] ?? "") === "active";
|
|
}
|
|
|
|
/**
|
|
* True for any feature not in PAID_FEATURES (nothing to gate) or when a
|
|
* license is active; false only when the feature is paid and unlicensed.
|
|
*
|
|
* Checked first, ahead of an actual license: if the gate files
|
|
* themselves have been tampered with (see Integrity::is_tampered()),
|
|
* every paid feature re-locks regardless of license status — a modified
|
|
* copy can't be redistributed showing paid features unlocked with no
|
|
* license at all.
|
|
*/
|
|
public static function is_feature_available($feature)
|
|
{
|
|
if (!isset(self::PAID_FEATURES[$feature])) {
|
|
return true;
|
|
}
|
|
if (Integrity::is_tampered()) {
|
|
return false;
|
|
}
|
|
return self::is_active();
|
|
}
|
|
|
|
public static function feature_label($feature)
|
|
{
|
|
return isset(self::PAID_FEATURES[$feature]) ? self::PAID_FEATURES[$feature] : "";
|
|
}
|
|
|
|
/**
|
|
* The license server's /validate response shape (see call()), cached in
|
|
* aurora_admin_license_status for STATUS_TTL so every paid-feature check
|
|
* across an admin page load — there are several per request — doesn't
|
|
* each cost a remote HTTP round trip. Re-validated immediately (not just
|
|
* on expiry) if the cached result was for a different key than what's
|
|
* currently saved, so a key change is never served stale data even if
|
|
* on_settings_updated() somehow didn't fire first.
|
|
*/
|
|
public static function status()
|
|
{
|
|
$key = trim((string) Settings::get("license_key", ""));
|
|
if ($key === "") {
|
|
return ["valid" => false, "status" => "none"];
|
|
}
|
|
|
|
$cached = get_option(self::STATUS_OPTION);
|
|
$fresh =
|
|
is_array($cached) &&
|
|
($cached["checked_key"] ?? null) === $key &&
|
|
($cached["checked_at"] ?? 0) > time() - self::STATUS_TTL;
|
|
|
|
if ($fresh) {
|
|
return $cached;
|
|
}
|
|
|
|
return self::validate($key);
|
|
}
|
|
|
|
public static function validate($key)
|
|
{
|
|
$result = self::call("validate", [
|
|
"license_key" => $key,
|
|
"domain" => self::domain(),
|
|
]);
|
|
|
|
$result["checked_key"] = $key;
|
|
$result["checked_at"] = time();
|
|
update_option(self::STATUS_OPTION, $result, false);
|
|
|
|
return $result;
|
|
}
|
|
|
|
private static function activate($key)
|
|
{
|
|
self::call("activate", [
|
|
"license_key" => $key,
|
|
"domain" => self::domain(),
|
|
]);
|
|
|
|
// /activate's response is a narrower shape ({activated, plan, ...} or
|
|
// an error) than /validate's ({valid, status, active_sites, ...}) —
|
|
// re-validate right after so the cached status has the full shape
|
|
// status()/is_active() and the Settings UI expect either way.
|
|
self::validate($key);
|
|
}
|
|
|
|
private static function domain()
|
|
{
|
|
// The server normalizes domain server-side (strips protocol/www/
|
|
// trailing slash) — a bare host is enough, no need to duplicate that
|
|
// normalization here.
|
|
return (string) parse_url(home_url(), PHP_URL_HOST);
|
|
}
|
|
|
|
/**
|
|
* POST $endpoint against the license server. Always returns a decoded
|
|
* array — network failures and non-JSON responses collapse into the
|
|
* same {valid:false, status:"unreachable", ...} shape as a real error
|
|
* response, so callers only ever need to branch on the response fields,
|
|
* never on WP_Error separately.
|
|
*/
|
|
private static function call($endpoint, $body)
|
|
{
|
|
$response = wp_remote_post(self::API_BASE . $endpoint, [
|
|
"timeout" => 8,
|
|
"headers" => ["Content-Type" => "application/json"],
|
|
"body" => wp_json_encode($body),
|
|
]);
|
|
|
|
if (is_wp_error($response)) {
|
|
return [
|
|
"valid" => false,
|
|
"status" => "unreachable",
|
|
"error" => "unreachable",
|
|
"message" => $response->get_error_message(),
|
|
];
|
|
}
|
|
|
|
$decoded = json_decode(wp_remote_retrieve_body($response), true);
|
|
if (!is_array($decoded)) {
|
|
return [
|
|
"valid" => false,
|
|
"status" => "unreachable",
|
|
"error" => "invalid_response",
|
|
"message" => "License server returned an unexpected response.",
|
|
];
|
|
}
|
|
|
|
return $decoded;
|
|
}
|
|
}
|