"Role Editor", "activity-log" => "Activity Log", "database-explorer" => "Database Explorer", "file-manager" => "File Manager", "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; } }