session ? WC()->session->get('refref_handle') : ''; return is_string($handle) ? $handle : ''; } public static function consent(): bool { if (!WC()->session || !WC()->session->get('refref_consent')) { return false; } return !function_exists('wp_has_consent') || wp_has_consent('marketing'); } public static function register(): void { add_action('wc_ajax_refref_bootstrap', [self::class, 'bootstrap']); add_action('wc_ajax_refref', [self::class, 'request']); add_action('wp_enqueue_scripts', static function () { if (!RefRef_Config::connection()) { return; } wp_enqueue_script('refref', plugins_url('storefront.js', REFREF_PLUGIN_FILE), ['wp-i18n'], '0.1.0', true); wp_set_script_translations('refref', 'refref'); wp_add_inline_script('refref', 'window.refrefStore=' . wp_json_encode(['endpoint' => WC_AJAX::get_endpoint('refref'), 'bootstrap' => WC_AJAX::get_endpoint('refref_bootstrap')]) . ';', 'before'); }); add_shortcode('refref', [self::class, 'surface']); add_action('woocommerce_account_dashboard', static function () { echo wp_kses_post(self::surface()); }); add_action('woocommerce_thankyou', static function () { echo wp_kses_post(self::surface()); }); add_filter('wp_privacy_personal_data_exporters', static function ($exporters) { $exporters['refref'] = ['exporter_friendly_name' => 'RefRef', 'callback' => [self::class, 'export']]; return $exporters; }); add_filter('wp_privacy_personal_data_erasers', static function ($erasers) { $erasers['refref'] = ['eraser_friendly_name' => 'RefRef', 'callback' => [self::class, 'erase']]; return $erasers; }); } public static function bootstrap(): void { nocache_headers(); WC()->session->set_customer_session_cookie(true); $csrf = WC()->session->get('refref_csrf'); if (!$csrf) { $csrf = bin2hex(random_bytes(32)); WC()->session->set('refref_csrf', $csrf); } wp_send_json(['csrf' => $csrf, 'consent' => self::consent(), 'signedIn' => is_user_logged_in(), 'nonces' => ['woocommerce-login-nonce' => wp_create_nonce('woocommerce-login'), 'woocommerce-register-nonce' => wp_create_nonce('woocommerce-register'), 'woocommerce-process-checkout-nonce' => wp_create_nonce('woocommerce-process_checkout')]]); } public static function request(): void { nocache_headers(); $input = json_decode(file_get_contents('php://input'), true); $csrf = WC()->session ? WC()->session->get('refref_csrf') : null; // Compare the unslashed Origin exactly; sanitizing it could turn an invalid origin into an allowed one. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Strict origin equality below validates the complete value. $origin = isset($_SERVER['HTTP_ORIGIN']) ? wp_unslash($_SERVER['HTTP_ORIGIN']) : ''; $expected = wp_parse_url(home_url(), PHP_URL_SCHEME) . '://' . wp_parse_url(home_url(), PHP_URL_HOST); $port = wp_parse_url(home_url(), PHP_URL_PORT); if ($port) { $expected .= ':' . $port; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Exact method comparison validates the complete value. $method = isset($_SERVER['REQUEST_METHOD']) ? wp_unslash($_SERVER['REQUEST_METHOD']) : ''; if ($method !== 'POST' || $origin !== $expected || !$csrf || !is_array($input) || !is_string($input['csrf'] ?? null) || !hash_equals($csrf, $input['csrf'])) { wp_send_json(['error' => __('Refresh this page and try again.', 'refref')], 403); } try { $action = $input['action'] ?? ''; if ($action === 'withdraw') { if (WC()->cart) { foreach (WC()->cart->get_applied_coupons() as $code) { $coupon = new WC_Coupon($code); if ($coupon->get_meta('_refref_handle')) { WC()->cart->remove_coupon($code); } } } foreach (['refref_handle', 'refref_guest', 'refref_consent'] as $key) { WC()->session->__unset($key); } wp_send_json(['status' => 'cleared']); } if ($action === 'capture') { if (($input['consent'] ?? false) === true) { WC()->session->set('refref_consent', true); } if (!self::consent()) { wp_send_json(['error' => __('Allow referral tracking before applying this offer.', 'refref')], 403); } $payload = ['handoffToken' => (string) ($input['handoffToken'] ?? '')]; $handle = self::handle_id(); if ($handle !== '') { $payload['handle'] = $handle; } $result = RefRef_Transport::shopper('capture', $payload); WC()->session->set('refref_handle', $result['handle']); wp_send_json(['status' => 'captured']); } if ($action === 'offer') { if (!self::consent() || self::handle_id() === '') { wp_send_json(['status' => 'unavailable']); } $result = RefRef_Transport::shopper('offer', ['handle' => self::handle_id()]); $result['cartChanged'] = false; if (($result['status'] ?? '') === 'ready' && WC()->cart && !WC()->cart->has_discount($result['code'])) { if (!WC()->cart->apply_coupon($result['code'])) { throw new RuntimeException(esc_html__('The offer does not apply to this cart.', 'refref')); } $result['cartChanged'] = true; } wp_send_json($result); } if (!in_array($action, ['share', 'account'], true)) { throw new RuntimeException(esc_html__('Unknown referral action.', 'refref')); } if (!is_user_logged_in()) { wp_send_json(['status' => 'sign_in_required']); } $pagination = []; foreach (['referralsCursor', 'rewardsCursor'] as $key) { if (is_string($input[$key] ?? null) && strlen($input[$key]) <= 2048) { $pagination[$key] = $input[$key]; } } wp_send_json(RefRef_Transport::shopper($action, ['customerId' => (string) get_current_user_id()], $pagination)); } catch (Throwable $error) { wp_send_json(['error' => $error->getMessage()], 503); } } public static function surface(): string { if (!RefRef_Config::connection()) { return ''; } if (!is_user_logged_in()) { return '

' . esc_html__('Sign in to get your referral link', 'refref') . '

'; } return '

' . esc_html__('Your referrals', 'refref') . '

'; } /** * The store's own customer and guest IDs of a verified WordPress privacy request. * WordPress uses the email to find its own account and orders; RefRef never receives the email * and never uses it to identify or merge Participants. */ private static function privacy_subjects(string $email): array { $user = get_user_by('email', $email); $guests = []; $orders = []; // Every order of the email, a page at a time, so no guest ID is left out. for ($page = 1; ; $page++) { $batch = wc_get_orders(['billing_email' => $email, 'limit' => 100, 'paged' => $page, 'orderby' => 'ID', 'order' => 'ASC']); foreach ($batch as $order) { $orders[] = $order; foreach (['_refref_guest', '_refref_paid_guest'] as $key) { $guest = $order->get_meta($key); if (is_string($guest) && wp_is_uuid($guest)) { $guests[] = $guest; } } } if (count($batch) < 100) { break; } } $payload = ['customerId' => $user ? (string) $user->ID : null, 'guests' => array_values(array_unique($guests))]; return [$payload['customerId'] || $payload['guests'] ? $payload : null, $orders]; } /** The WordPress request ID, so a repeated callback converges on the same hosted request. */ private static function privacy_request_id(string $email, string $action): string { // WordPress checks the privacy screen capability and nonce before invoking this callback. // The ID only correlates the request; the trusted callback email/action must also match. // phpcs:ignore WordPress.Security.NonceVerification.Missing $id = isset($_POST['id']) ? absint(wp_unslash($_POST['id'])) : 0; $request = $id ? wp_get_user_request($id) : false; if ($request && $request->email === $email && $request->action_name === $action) { return (string) $id; } $ids = get_posts(['post_type' => 'user_request', 'post_name__in' => [$action], 'title' => $email, 'post_status' => ['request-confirmed', 'request-completed'], 'numberposts' => 1, 'orderby' => 'ID', 'order' => 'DESC', 'fields' => 'ids']); return $ids ? (string) $ids[0] : 'manual-' . time(); } private static function privacy_call(string $action, string $email, array $payload, string $request_action) { $payload['requestId'] = self::privacy_request_id($email, $request_action); try { return RefRef_Transport::request('/integrations/v1/woocommerce/privacy', RefRef_Transport::proof($action, $payload)); } catch (RuntimeException $error) { return new WP_Error('refref_privacy', sprintf(/* translators: %s: the connection error. */ __('RefRef could not complete this request: %s Try again, or contact RefRef support.', 'refref'), $error->getMessage())); } } public static function export($email, $page = 1) { // A store that was never connected, or was disconnected, has no hosted records to read. if (!RefRef_Config::connection()) { return ['data' => [], 'done' => true]; } [$payload] = self::privacy_subjects($email); if (!$payload) { return ['data' => [], 'done' => true]; } $result = self::privacy_call('privacy-export', $email, $payload, 'export_personal_data'); if (is_wp_error($result)) { return $result; } $items = []; foreach (($result['exports'] ?? []) as $index => $export) { $data = []; foreach ((array) $export as $name => $value) { $data[] = ['name' => (string) $name, 'value' => is_scalar($value) ? (string) $value : wp_json_encode($value)]; } $items[] = ['group_id' => 'refref', 'group_label' => __('RefRef referrals', 'refref'), 'item_id' => 'refref-' . $index, 'data' => $data]; } return ['data' => $items, 'done' => true]; } /** * RefRef soft deletes the hosted records and later anonymises them under its privacy policy. */ public static function erase($email, $page = 1) { if (!RefRef_Config::connection()) { return ['items_removed' => false, 'items_retained' => true, 'messages' => [__('RefRef is not connected, so it could not delete hosted referral records. Ask RefRef support to delete them.', 'refref')], 'done' => true]; } [$payload, $orders] = self::privacy_subjects($email); if (!$payload) { return ['items_removed' => false, 'items_retained' => false, 'messages' => [], 'done' => true]; } $result = self::privacy_call('privacy-erase', $email, $payload, 'remove_personal_data'); if (is_wp_error($result)) { return $result; } $removed = (int) ($result['removed'] ?? 0) > 0; if ($removed) { // The browser handle and the session guest ID of these orders go. The paid-order // snapshot stays: refunds of these orders still need it for accounting. foreach ($orders as $order) { $order->delete_meta_data('_refref_handle'); $order->delete_meta_data('_refref_guest'); $order->save(); } } return ['items_removed' => $removed, 'items_retained' => $removed, 'messages' => $removed ? [__('RefRef deleted the referral records of this customer. Order and reward accounting records are kept.', 'refref')] : [], 'done' => true]; } }