Files
aurora-wordpress-admin/admin/src/Licensing/License.php
T
nixpanelandClaude Sonnet 5 15d6060ae3 Add File Manager sub-app (Pro feature) (v2.7.0)
New browse/edit interface for the WordPress install's own files, built
using the installed "WP File Manager" plugin (elFinder-based) as a
functional reference rather than embedding it. Own REST controller
(FileManagerData.php) + Vue page, same pattern as every other sub-app.

Security is the whole point here (highest-risk surface in the plugin —
arbitrary filesystem read/write):
- Root is ABSPATH, manage_options-gated throughout.
- Every client path is resolved via realpath() and checked for root
  containment against the *resolved* path, not string-matched against
  the raw input — catches symlink tricks and encoded traversal that a
  naive "../" check would miss.
- Uploads/new files/renames reject a blocklist of server-executable
  extensions (.php, .cgi, .sh, etc.) — the single most important
  control, since uploading a webshell is the classic exploit path for
  this class of feature.
- Deliberately not built: zip/unzip (zip-slip vulnerability class),
  chmod/permissions editing, cross-directory move/copy.

Gated as a paid feature (riskier than Database Explorer, which is
already paid) — a judgment call flagged to the user, not something they
specified.

Also fixed a real bug found while wiring this up: SubApps.php's
page-level lock doesn't stop someone with manage_options from calling
the REST endpoints directly, bypassing the license check entirely.
File Manager's REST permission callbacks now also check
License::is_feature_available(). The same gap exists in Database
Explorer, Role Editor, and Activity Log's REST controllers and is
flagged as a follow-up task, not fixed here (out of scope for what was
asked, affects three unrelated features).

Verified live via direct REST calls: path traversal blocked (404),
blocked extensions rejected (400), root deletion blocked (400), and a
full create/write/read/rename/delete round trip succeeded cleanly.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-05 06:59:24 -05:00

206 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",
"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;
}
}