'GET', 'callback' => array( __CLASS__, 'me' ), 'permission_callback' => 'is_user_logged_in', ) ); if ( Settings::module_on( 'spin' ) ) { register_rest_route( self::NS, '/spin', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'spin' ), 'permission_callback' => 'is_user_logged_in', ) ); } if ( Settings::module_on( 'draw' ) ) { register_rest_route( self::NS, '/draws/(?P\d+)/verify', array( 'methods' => 'GET', 'callback' => array( __CLASS__, 'verify' ), 'permission_callback' => '__return_true', // Public transparency data; no personal data returned. 'args' => array( 'id' => array( 'sanitize_callback' => 'absint', ), ), ) ); } } /** * Current customer's rewards summary. * * @return WP_REST_Response */ public static function me() { $user_id = get_current_user_id(); $data = array( 'balance' => Ledger::balance( $user_id ), 'label' => Settings::get( 'coins.plural' ), ); if ( Settings::module_on( 'spin' ) ) { $data['spins'] = Tokens::available( $user_id ); } if ( Settings::module_on( 'tiers' ) ) { $status = \LoyaltyPress\Discounts\Tiers::status( $user_id ); $data['tier'] = $status['current'] ? $status['current']['name'] : null; } return rest_ensure_response( $data ); } /** * Spin the wheel. Outcome is decided on the server. * * @return WP_REST_Response|WP_Error */ public static function spin() { $user_id = get_current_user_id(); $max = max( 1, (int) Settings::get( 'spin.max_spins_per_hour', 10 ) ); if ( ! Rate_Limiter::hit( 'spin:' . $user_id, $max, HOUR_IN_SECONDS ) || ! Rate_Limiter::hit( 'spin-burst:' . $user_id, 1, 3 ) ) { return new WP_Error( 'loyaltypress_rate', __( 'Please wait a moment before spinning again.', 'loyaltyspin' ), array( 'status' => 429 ) ); } $result = Spin::spin( $user_id ); return is_wp_error( $result ) ? $result : rest_ensure_response( $result ); } /** * Public draw verification data. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public static function verify( WP_REST_Request $request ) { $draw = Draws::get( (int) $request['id'] ); if ( ! $draw || empty( $draw['config']['show_public'] ) ) { return new WP_Error( 'loyaltypress_missing', __( 'Draw not found.', 'loyaltyspin' ), array( 'status' => 404 ) ); } $out = array( 'draw' => (int) $draw['id'], 'name' => $draw['name'], 'status' => $draw['status'], 'pre_commit_hash' => $draw['pre_commit_hash'], 'algorithm' => 'fisher-yates/hmac-sha256-ctr/v1', ); if ( 'drawn' === $draw['status'] ) { $drawn = null; foreach ( Draws::log_entries( (int) $draw['id'] ) as $log ) { if ( 'drawn' === $log['event'] ) { $drawn = json_decode( $log['payload'], true ); } } $out['seed'] = $drawn['seed'] ?? null; $out['nonce'] = $drawn['nonce'] ?? null; $out['snapshot_hash'] = $draw['snapshot_hash']; $out['drawn_at'] = $draw['drawn_at']; $out['entry_ids'] = Draws::snapshot_ids( (int) $draw['id'] ); $out['winner_entry_ids'] = array_map( 'intval', wp_list_pluck( Draws::winners( (int) $draw['id'] ), 'entry_id' ) ); $out['how_to_verify'] = 'sha256(seed) must equal pre_commit_hash; sha256(sorted entry_ids joined by ",") must equal snapshot_hash; key = sha256(seed|snapshot_hash|nonce); Fisher-Yates over entry_ids ascending using HMAC-SHA256(key, uint64_be counter) as a 32-bit big-endian stream with rejection sampling; take entries in order, skipping repeat customers when one-win is on.'; } return rest_ensure_response( $out ); } }