'POST', 'callback' => [__CLASS__, 'activate'], 'permission_callback' => function() { return current_user_can('manage_options'); }, 'args' => [ 'key' => ['required' => true, 'type' => 'string'], 'meta' => ['required' => false, 'type' => 'object'], ], ]); register_rest_route('a11ybridge/v1', '/license/status', [ 'methods' => 'POST', 'callback' => [__CLASS__, 'status'], 'permission_callback' => function() { return current_user_can('manage_options'); }, 'args' => [ 'meta' => ['required' => false, 'type' => 'object'], ], ]); } /** POST /license/activate */ public static function activate(WP_REST_Request $req) { $key = trim((string) ($req->get_param('key') ?? '')); $meta = (array) ($req->get_param('meta') ?? []); if ($key === '') { return self::error( 'missing_key', __('License key is missing.', 'a11ybridge'), 400 ); } // Determine domain / site (server-side is authoritative) $site_url = site_url(); $domain = wp_parse_url($site_url, PHP_URL_HOST) ?: $site_url; // Store only hash locally (no plaintext license key) $key_hash = hash('sha256', $key); // Payload for external license server $payload = [ 'license_key' => $key, // external server needs the plaintext key 'license_hash' => $key_hash, // optional 'domain' => $domain, 'wp_version' => get_bloginfo('version'), 'plugin_ver' => A11YBRIDGE_VERSION, 'meta' => self::sanitize_meta($meta), ]; $resp = self::remote_json('/license/activate', $payload); if (is_wp_error($resp)) { return self::error('network', $resp->get_error_message(), 502); } $code = wp_remote_retrieve_response_code($resp); $body = json_decode(wp_remote_retrieve_body($resp), true) ?: []; if ($code < 200 || $code >= 300) { $msg = isset($body['error']) ? (string) $body['error'] : ('HTTP '.$code); return self::error( 'activate_failed', /* translators: %s: error message from license server */ sprintf(__('Activation failed: %s', 'a11ybridge'), $msg), 400, $body ); } // Expected fields from license server (shape your server response accordingly) $plan = isset($body['plan']) ? (string) $body['plan'] : 'free'; $monthly_limit = isset($body['monthly_limit']) ? (int) $body['monthly_limit'] : 100; $monthly_used = isset($body['monthly_used']) ? (int) $body['monthly_used'] : 0; $reset_date_iso = isset($body['reset_date']) ? (string) $body['reset_date'] : null; // ISO 8601 $status = isset($body['status']) ? (string) $body['status'] : 'active'; // Store locally (only the key hash, never the plaintext key) update_option('a11yb_license_domain', $domain, false); update_option('a11yb_license_key_hash', $key_hash, false); update_option('a11yb_plan_level', $plan, false); update_option('a11yb_monthly_limit', max(0, $monthly_limit), false); update_option('a11yb_monthly_used', max(0, $monthly_used), false); if ($reset_date_iso) { update_option('a11yb_reset_date', $reset_date_iso, false); } update_option('a11yb_license_status', $status, false); return new WP_REST_Response([ 'message' => __('License successfully activated.', 'a11ybridge'), 'plan' => $plan, 'monthly_limit' => (int) get_option('a11yb_monthly_limit', 100), 'monthly_used' => (int) get_option('a11yb_monthly_used', 0), 'reset_date' => get_option('a11yb_reset_date'), 'status' => $status, 'domain' => $domain, ], 200); } /** POST /license/status */ public static function status(WP_REST_Request $req) { $meta = (array) ($req->get_param('meta') ?? []); $result = self::refresh_status_and_options($meta); if (is_wp_error($result)) { $data = $result->get_error_data() ?: []; $status = isset($data['status']) ? (int) $data['status'] : 400; return self::error( $result->get_error_code(), $result->get_error_message(), $status, isset($data['body']) ? $data['body'] : null ); } $site_url = site_url(); $domain = wp_parse_url($site_url, PHP_URL_HOST) ?: $site_url; return new WP_REST_Response([ 'message' => __('License status updated.', 'a11ybridge'), 'plan' => (string) get_option('a11yb_plan_level', 'free'), 'monthly_limit' => (int) get_option('a11yb_monthly_limit', 100), 'monthly_used' => (int) get_option('a11yb_monthly_used', 0), 'reset_date' => get_option('a11yb_reset_date'), 'status' => (string) get_option('a11yb_license_status', 'unknown'), 'domain' => $domain, ], 200); } // ---------- Utilities ---------- /** * Minimal sanitization for meta data – extend as needed. */ private static function sanitize_meta(array $meta): array { foreach ($meta as $k => $v) { if (is_string($v)) { $meta[$k] = sanitize_text_field($v); } } return $meta; } /** * Sends JSON payload to external license server. * * @param string $path * @param array $payload * @return array|WP_Error */ private static function remote_json(string $path, array $payload, array $extra_headers = []) { $url = rtrim(self::LICENSE_API_BASE, '/') . $path; // Optional: HMAC signature with shared secret // $secret = defined('A11YB_LICENSE_SECRET') ? A11YB_LICENSE_SECRET : ''; // $signature = $secret ? hash_hmac('sha256', wp_json_encode($payload), $secret) : ''; $args = [ 'timeout' => self::TIMEOUT_SECONDS, 'headers' => array_merge([ 'Content-Type' => 'application/json', // 'X-A11YB-Signature' => $signature, ], $extra_headers), 'body' => wp_json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), ]; return wp_safe_remote_post($url, $args); } /** * Unified REST error response helper. * * @param string $code * @param string $msg * @param int $status * @param array|null $extra * @return WP_REST_Response */ private static function error(string $code, string $msg, int $status = 400, ?array $extra = null) { $data = [ 'error' => $code, 'message' => $msg, ]; if (is_array($extra)) { $data = array_merge($data, $extra); } return new WP_REST_Response($data, $status); } /** * Central method: fetches status from backend and updates local options. * Can be called from PHP or via the REST callback. * * @param array $meta Optional metadata * @return array|WP_Error */ public static function refresh_status_and_options(array $meta = []) { $site_url = site_url(); $domain = wp_parse_url($site_url, PHP_URL_HOST) ?: $site_url; // Read Pro license settings $pro = get_option('a11ybridge_pro_license_settings', []); if (!is_array($pro)) { $pro = []; } $license_hash = isset($pro['license_hash']) ? (string) $pro['license_hash'] : ''; // Optional fallback to legacy option if ($license_hash === '') { $license_hash = (string) get_option('a11yb_license_key_hash', ''); } // ------------------------------------------------------------ // Free plan support: // If no license hash exists, we still want to show accurate // counters for the Free plan (tracked by installation_id). // ------------------------------------------------------------ $is_paid = ($license_hash !== ''); if (!$is_paid) { // Stable installation id for Free plan (stored server-side as WP option). // IMPORTANT: Do not expose it to visitors; only send it to the A11yBridge backend. $install_id = function_exists('a11ybridge_get_install_id') ? a11ybridge_get_install_id() : (string) get_option('a11yb_install_id', ''); if ($install_id === '') { // Fallback (should normally never happen) $install_id = wp_generate_uuid4(); update_option('a11yb_install_id', $install_id, true); } // WP.org compliance: do not contact external servers unless the site owner opted in // (e.g. explicitly enabling cloud services in the Pro tab). $cloud_ok = (function_exists('a11ybridge_cloud_opted_in') && a11ybridge_cloud_opted_in()); $user_action = !empty($meta['user_action']); if (!$cloud_ok && !$user_action) { // Keep local counters only (no remote call). $fallback_limit = (int) get_option('a11yb_monthly_limit', 100); if ($fallback_limit <= 0) { $fallback_limit = 100; } $local_used = max(0, (int) get_option('a11yb_monthly_used', 0)); $local_remaining = max(0, $fallback_limit - $local_used); update_option('a11yb_plan_level', 'free', false); update_option('a11yb_monthly_limit', $fallback_limit, false); update_option('a11yb_ai_credits_remaining', $local_remaining, false); return [ 'plan' => 'free', 'monthly_limit' => $fallback_limit, 'monthly_used' => $local_used, 'remaining' => $local_remaining, 'status' => 'local_opt_out', ]; } $payload = [ 'installation_id' => $install_id, 'domain' => $domain, 'meta' => self::sanitize_meta($meta), ]; // Also pass as header for maximum compatibility. $resp = self::remote_json('/free/status', $payload, [ 'X-AB-Installation-Id' => $install_id, ]); } else { $payload = [ 'license_hash' => $license_hash, 'domain' => $domain, 'meta' => self::sanitize_meta($meta), ]; $resp = self::remote_json('/license/status', $payload); } if (is_wp_error($resp)) { return $resp; } $code = wp_remote_retrieve_response_code($resp); $body = json_decode(wp_remote_retrieve_body($resp), true) ?: []; if ($code < 200 || $code >= 300) { $msg = isset($body['error']) ? (string) $body['error'] : ('HTTP '.$code); return new WP_Error( 'status_failed', /* translators: %s: error message from license server */ sprintf(__('Status check failed: %s', 'a11ybridge'), $msg), ['status' => 400, 'body' => $body] ); } // Update local mirror if (isset($body['plan'])) { update_option('a11yb_plan_level', (string) $body['plan'], false); } // --- New canonical logic --- // Limit comes from plan, remaining comes from backend DB. // Used = limit - remaining. $plan = isset($body['plan']) ? (string) $body['plan'] : (string) get_option('a11yb_plan_level', 'free'); // Monthly limit must be provided by backend (source of truth). // Fallback is the last known stored value, or the Free default (100). $fallback_limit = (int) get_option('a11yb_monthly_limit', 100); if ($fallback_limit <= 0) { $fallback_limit = 100; } $monthly_limit = isset($body['monthly_limit']) ? max(0, (int) $body['monthly_limit']) : $fallback_limit; $remaining = null; if (isset($body['remaining'])) { $remaining = max(0, (int) $body['remaining']); } // Handle top-ups / manual grants: if remaining > limit, show limit=remaining, used=0 if ($remaining !== null && $remaining > $monthly_limit) { $monthly_limit = $remaining; } $monthly_used = null; if ($remaining !== null) { $monthly_used = max(0, $monthly_limit - $remaining); } elseif (isset($body['monthly_used'])) { // Legacy fallback if backend still sends it $monthly_used = max(0, (int) $body['monthly_used']); } else { $monthly_used = (int) get_option('a11yb_monthly_used', 0); } // Update local mirror update_option('a11yb_plan_level', $plan, false); update_option('a11yb_monthly_limit', $monthly_limit, false); update_option('a11yb_monthly_used', $monthly_used, false); if (isset($body['reset_date'])) { update_option('a11yb_reset_date', (string) $body['reset_date'], false); } if (isset($body['status'])) { update_option('a11yb_license_status', (string) $body['status'], false); } if ($remaining !== null) { update_option('a11yb_ai_credits_remaining', $remaining, false); } if (isset($body['license_created_at'])) { update_option('a11yb_license_created_at', (string) $body['license_created_at'], false); } if (isset($body['license_expires_at'])) { update_option('a11yb_license_expires_at', (string) $body['license_expires_at'], false); } if (isset($body['environment'])) { update_option('a11yb_license_environment', (string) $body['environment'], false); } if (isset($body['period'])) { update_option('a11yb_usage_period', (string) $body['period'], false); } if (isset($body['license_state'])) { update_option('a11yb_license_state', (string) $body['license_state'], false); } if (isset($body['next_update_at'])) { update_option('a11yb_next_update_at', (string) $body['next_update_at'], false); } // Optional: reset_date im Plugin immer aus next_update_at überschreiben (falls du im UI noch reset_date verwendest) if (!empty($body['next_update_at'])) { update_option('a11yb_reset_date', (string) $body['next_update_at'], false); } // Canonical timestamps from backend if (isset($body['license_created_at_ts'])) { update_option('a11yb_license_created_at_ts', (int)$body['license_created_at_ts'], false); } if (isset($body['license_expires_at_ts'])) { update_option('a11yb_license_expires_at_ts', (int)$body['license_expires_at_ts'], false); } if (isset($body['license_updated_at_ts'])) { update_option('a11yb_license_updated_at_ts', (int)$body['license_updated_at_ts'], false); } if (isset($body['next_update_at_ts'])) { update_option('a11yb_next_update_at_ts', (int)$body['next_update_at_ts'], false); } if (isset($body['reset_date_ts'])) { update_option('a11yb_reset_date_ts', (int)$body['reset_date_ts'], false); } // Fallback: if only ISO exists, parse once into _ts (UTC) if (!get_option('a11yb_license_expires_at_ts') && !empty($body['license_expires_at'])) { try { $dt = new \DateTime((string)$body['license_expires_at'], new \DateTimeZone('UTC')); update_option('a11yb_license_expires_at_ts', $dt->getTimestamp(), false); } catch (\Throwable $e) {} } if (!get_option('a11yb_next_update_at_ts') && !empty($body['next_update_at'])) { try { $dt = new \DateTime((string)$body['next_update_at'], new \DateTimeZone('UTC')); update_option('a11yb_next_update_at_ts', $dt->getTimestamp(), false); } catch (\Throwable $e) {} } return $body; } /** * Converts a long subscription name into a short tier slug. * * @param string $long_name * @return string Short plan marker (free|paid). */ public static function shorten_plan($long_name) { // WP.org build: avoid tier marketing semantics in the codebase. // We only need to know whether the installation is "free" or "subscribed". $name = strtolower(trim((string) $long_name)); if ($name === '' || strpos($name, 'free') !== false) { return 'free'; } return 'paid'; } } A11YB_License_API::init();