false, 'error' => (string) ( $result['error_text'] ?? self::error_text( $result['error'] ) ), 'code' => $result['error'] ], 400 ); } $payment = $result['payment']; wp_send_json( [ 'ok' => true, 'mode' => $payment->mode(), 'pay_url' => Button::PAY_PAGE === $payment->mode() ? $payment->pay_url() : '', 'state' => Button::state( $payment ), 'key' => $payment->view_key(), ] ); } // Without JavaScript: on to the payment page, or back to the button with the outcome. if ( ! $result['ok'] ) { $back = add_query_arg( [ Button::QUERY => 'error', 'e' => $result['error'], 'b' => $button ], Button::page_url( (int) ( $result['post'] ?? 0 ) ) ) . '#cwbtn-' . $button; wp_safe_redirect( $back, 303 ); exit; } $payment = $result['payment']; if ( Button::PAY_PAGE === $payment->mode() && '' !== $payment->pay_url() ) { // The pay page is on a Cryptoway domain (checked when the invoice was saved). self::redirect_to( $payment->pay_url() ); } wp_safe_redirect( self::done_url( $payment ), 303 ); exit; } /** * A form whose nonce did not verify, usually a page kept in a cache longer than a nonce lives: * nothing is saved or sent. The script fetches a fresh nonce and tries again; without it the * visitor lands on the button's page, never served from a cache, with a fresh form. */ private static function expired( string $packed, string $sig, bool $wants_json ): void { if ( $wants_json ) { wp_send_json( [ 'ok' => false, 'error' => self::error_text( 'expired' ), 'code' => 'expired' ], 403 ); } $cfg = Button::unpack( $packed, $sig ); $button = null === $cfg ? '' : Button::key( $cfg ); $back = add_query_arg( [ Button::QUERY => 'error', 'e' => 'expired', 'b' => $button ], Button::page_url( (int) ( $cfg['post'] ?? 0 ) ) ); wp_safe_redirect( $back . '#cwbtn-' . $button, 303 ); exit; } /** * Fresh nonces for the forms of a page served from a cache. Reads no input and stores nothing; * the answer carries no CORS headers, so only the site's own pages can read it. A visitor who is * not logged in gets the nonces every page with a button already shows; a logged-in user gets * the ones of their own session. */ private static function handle_nonce(): void { self::no_cache(); wp_send_json( [ 'pay' => wp_create_nonce( self::NONCE_PAY ), 'recover' => wp_create_nonce( self::NONCE_RECOVER ) ] ); } /** Whether the browser asked for JSON (the script) rather than a page. */ private static function accepts_json(): bool { return str_contains( sanitize_text_field( wp_unslash( (string) ( $_SERVER['HTTP_ACCEPT'] ?? '' ) ) ), 'application/json' ); } /** The key of the button a form was sent from, for the anchor of the way back; '' for a broken form. */ private static function button_of( array $post ): string { $cfg = Button::unpack( (string) ( $post['cwbtn_cfg'] ?? '' ), (string) ( $post['cwbtn_sig'] ?? '' ) ); return null === $cfg ? '' : Button::key( $cfg ); } /** * Check the form and start the payment. * * @return array{ok: bool, error: string, payment: ?Payment, post: int} */ public static function start( array $post ): array { $cfg = Button::unpack( (string) ( $post['cwbtn_cfg'] ?? '' ), (string) ( $post['cwbtn_sig'] ?? '' ) ); if ( null === $cfg || self::from_another_site() ) { return self::fail( 'button' ); } $source_post = (int) ( $cfg['post'] ?? 0 ); $button = Button::key( $cfg ); if ( ! Settings::keys_present() ) { return self::fail( 'keys', $source_post ); } // A paid button whose price or access period changed since the form was drawn (a saved page, // a page cache): the visitor reloads and pays the current terms. if ( ! Button::is_current( $cfg ) ) { return self::fail( 'button', $source_post ); } // The honeypot was filled in: a script, not a person. if ( '' !== trim( (string) ( $post['cwbtn_website'] ?? '' ) ) ) { return self::fail( 'button', $source_post ); } // Amount. if ( Button::is_fixed( $cfg ) ) { $amount = $cfg['amount']; } else { $typed = Button::clean_amount( str_replace( ',', '.', sanitize_text_field( (string) ( $post['cwbtn_amount'] ?? '' ) ) ) ); $preset = Button::clean_amount( sanitize_text_field( (string) ( $post['cwbtn_preset'] ?? '' ) ) ); // A button that offers only its suggested amounts takes nothing typed. if ( 'no' === $cfg['custom'] ) { $typed = ''; } $amount = '' !== $typed ? $typed : $preset; if ( '' === $amount ) { return self::fail( 'amount', $source_post ); } if ( '' !== $preset && '' === $typed && ! in_array( $preset, explode( ',', $cfg['amounts'] ), true ) ) { return self::fail( 'amount', $source_post ); } if ( -1 === Amounts::compare( $amount, $cfg['min'] ) ) { /* translators: %s: smallest amount with currency */ return self::fail( 'min', $source_post, sprintf( __( 'The smallest amount is %s.', 'cryptoway-button' ), Amounts::fiat( $cfg['min'], $cfg['currency'] ) ) ); } } // Coin, for the on-site payment. $symbol = ''; if ( Button::PAY_ONSITE === $cfg['pay'] ) { $symbol = Coins::normalise( sanitize_text_field( (string) ( $post['cwbtn_coin'] ?? '' ) ) ); if ( '' === $symbol || ! isset( Button::coins_of( $cfg['coins'] )[ $symbol ] ) ) { return self::fail( 'coin', $source_post ); } } // Payer fields the button asked for. $payer = []; $fields = '' !== $cfg['fields'] ? explode( ',', $cfg['fields'] ) : []; if ( in_array( 'name', $fields, true ) ) { $payer['name'] = mb_substr( sanitize_text_field( (string) ( $post['cwbtn_name'] ?? '' ) ), 0, Button::MAX_NAME ); } if ( in_array( 'email', $fields, true ) || Button::AFTER_CONTENT === $cfg['after'] ) { $raw = (string) ( $post['cwbtn_email'] ?? '' ); $email = sanitize_email( $raw ); if ( '' !== $raw && ! is_email( $email ) ) { return self::fail( 'email', $source_post ); } // Paid content: the access link goes to this e-mail, so it cannot be left empty. if ( '' === $email && Button::AFTER_CONTENT === $cfg['after'] ) { return self::fail( 'email_required', $source_post ); } $payer['email'] = $email; } if ( in_array( 'message', $fields, true ) ) { $payer['message'] = mb_substr( sanitize_textarea_field( (string) ( $post['cwbtn_message'] ?? '' ) ), 0, Button::MAX_MESSAGE ); } // A second press in the same browser reuses its open invoice: only the browser that holds the // payment's view key can claim it. Nobody else from the same address (an office, a mobile // carrier, a site behind a proxy) gets someone else's invoice or access (tech review 2026-10-12). $existing = self::resumable( (string) ( $post['cwbtn_resume'] ?? '' ), $button, $source_post, $amount, $cfg['currency'], $symbol, $payer ); if ( $existing && ( Button::PAY_PAGE === $cfg['pay'] ? '' !== $existing->pay_url() : '' !== $existing->address() ) ) { return [ 'ok' => true, 'error' => '', 'payment' => $existing, 'post' => $source_post ]; } // Per visitor, and for the whole site: a page elsewhere can make many visitors' browsers press. if ( ! self::within_rate_limit( self::ip_hash() ) || ! self::within_rate_limit( 'all', 'site', self::site_limit() ) ) { return self::fail( 'limit', $source_post ); } $payment = Payment::create( [ 'title' => '' !== $cfg['title'] ? $cfg['title'] : self::default_title(), 'amount' => $amount, 'currency' => $cfg['currency'], 'mode' => $cfg['pay'], 'after' => $cfg['after'], 'message' => $cfg['message'], 'redirect' => $cfg['redirect'], 'access' => $cfg['access'], 'button' => $button, 'source_post' => $source_post, 'payer' => $payer, 'symbol' => $symbol, ] ); if ( ! $payment ) { Log::write( 'Could not create a payment record (wp_insert_post failed).' ); return self::fail( 'api', $source_post ); } $created = self::create_invoice( $payment, $cfg, $symbol ); if ( '' !== $created ) { return self::fail( $created, $source_post, self::for_admin( $created ) ); } return [ 'ok' => true, 'error' => '', 'payment' => $payment, 'post' => $source_post ]; } /** * The open payment a browser started from this button and asks to continue (":", * kept by the script): the same amount, coin and payer details, the invoice still payable. */ private static function resumable( string $token, string $button, int $source_post, string $amount, string $currency, string $symbol, array $payer ): ?Payment { if ( 1 !== preg_match( '/^(\d{1,20}):([0-9a-f]{32})$/', $token, $m ) ) { return null; } $payment = Payment::get( (int) $m[1] ); if ( ! $payment || ! hash_equals( $payment->view_key(), $m[2] ) ) { return null; } $same = $payment->button() === $button && $payment->source_post() === $source_post && Payment::STATUS_PENDING === $payment->status() && $payment->amount() === $amount && $payment->currency() === $currency && $payment->meta( Payment::META_REQUESTED_SYMBOL ) === $symbol // Changed details (an e-mail typo fixed) start a new payment: the access link must go where the payer says. && self::same_payer( $payment->payer(), $payer ); return $same && $payment->link_is_alive() ? $payment : null; } /** Whether two sets of payer details say the same (empty fields do not count). */ private static function same_payer( array $stored, array $sent ): bool { $sent = array_filter( array_map( 'strval', $sent ), static fn( string $v ): bool => '' !== trim( $v ) ); if ( isset( $sent['email'] ) ) { $sent['email'] = strtolower( $sent['email'] ); } if ( isset( $stored['email'] ) ) { $stored['email'] = strtolower( $stored['email'] ); } ksort( $stored ); ksort( $sent ); return $stored === $sent; } /** * Ask Cryptoway for the invoice and keep its data on the payment. * * @return string '' on success, else an error code for error_text(). */ private static function create_invoice( Payment $payment, array $cfg, string $symbol ): string { self::$why = ''; $site = wp_specialchars_decode( (string) get_option( 'blogname' ), ENT_QUOTES ); $lifetime = Settings::lifetime(); $deadline = gmdate( 'Y-m-d\TH:i:s\Z', time() + $lifetime * 60 ); $params = [ 'name' => $payment->title(), /* translators: 1: payment number, 2: site name */ 'description' => sprintf( __( 'Payment #%1$d at %2$s', 'cryptoway-button' ), $payment->id(), $site ), 'target_value_fiat' => (float) $payment->amount(), 'target_currency_fiat' => $payment->currency(), 'external_id' => $payment->external_id(), 'callback_url' => rest_url( Rest::NAMESPACE . '/webhook' ), 'deadline_at' => $deadline, ]; if ( Button::PAY_PAGE === $cfg['pay'] ) { $params['redirect_url'] = self::return_url( $payment ); } if ( '' !== $symbol ) { $params['symbol_id'] = $symbol; } $result = ApiClient::from_settings()->create_invoice( $params ); if ( is_wp_error( $result ) ) { $data = $result->get_error_data(); $data = is_array( $data ) ? $data : []; self::$why = (string) ( ( $data['skew'] ?? '' ) ?: ( ( $data['hint'] ?? '' ) ?: sanitize_text_field( $result->get_error_message() ) ) ); $payment->set_status( Payment::STATUS_FAILED ); // The note gives the plain reason; the raw answer of the API stays in the log. $payment->add_history( sprintf( /* translators: %s - error message from the API */ __( 'Cryptoway: could not create the invoice — %s', 'cryptoway-button' ), self::$why ) ); $status = (int) ( $result->get_error_data()['status'] ?? 0 ); return 422 === $status && '' !== $symbol ? 'coin' : 'api'; } $pay_url = (string) ( $result['pay_url'] ?? '' ); if ( '' !== $pay_url && ! self::pay_url_is_trusted( $pay_url ) ) { $payment->set_status( Payment::STATUS_FAILED ); $payment->add_history( sprintf( /* translators: %s - the payment link that was refused */ __( 'Cryptoway: refused to send the payer to %s — that address is not a Cryptoway domain.', 'cryptoway-button' ), $pay_url ) ); Log::write( 'Refused a pay_url outside the Cryptoway domains: ' . $pay_url ); return 'api'; } $manager = new PaymentManager( $payment ); $manager->save_invoice_data( $result ); if ( Button::PAY_PAGE === $cfg['pay'] && '' === $pay_url ) { $payment->set_status( Payment::STATUS_FAILED ); $payment->add_history( __( 'Cryptoway: no payment link was returned.', 'cryptoway-button' ) ); return 'api'; } if ( Button::PAY_ONSITE === $cfg['pay'] && '' === $payment->address() ) { // No address came back with the coin: fall back to the Cryptoway page, where the payer picks it. if ( '' === $pay_url ) { $payment->set_status( Payment::STATUS_FAILED ); $payment->add_history( __( 'Cryptoway: no payment address was returned for the chosen coin.', 'cryptoway-button' ) ); return 'api'; } $payment->set_meta( Payment::META_MODE, Button::PAY_PAGE ); $payment->add_history( __( 'Cryptoway: no address came back for the chosen coin; the payer is sent to the Cryptoway payment page instead.', 'cryptoway-button' ) ); } $payment->set_status( Payment::STATUS_PENDING ); // With the coin chosen on the site the invoice is already in coins: the note names them, as the // later notes do. On the Cryptoway page the payer picks the coin there, so only the price is known. $amounts = $manager->shown_amounts(); if ( '' !== $amounts['invoiced'] && '' !== $amounts['coin'] ) { $payment->add_history( sprintf( /* translators: 1: amount with coin and network, e.g. "6.09 USDT (BEP20)", 2: minutes to pay */ __( 'Cryptoway: invoiced %1$s, waiting for payment; time to pay: %2$d min.', 'cryptoway-button' ), Amounts::with_coin( $amounts['invoiced'], $amounts['coin'] ), $lifetime ) ); } else { $payment->add_history( sprintf( /* translators: 1: amount with currency, 2: minutes to pay */ __( 'Cryptoway: invoice created for %1$s; time to pay: %2$d min.', 'cryptoway-button' ), Amounts::fiat( $payment->amount(), $payment->currency() ), $lifetime ) ); } return ''; } // ------------------------------------------------------------------ the return /** * The visitor is back from the Cryptoway payment page. The address Cryptoway was given carries * the payment's return key, which leads only here; the view key that opens paid content is never * sent to Cryptoway. */ private static function handle_return(): void { self::no_cache(); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- the return address given to Cryptoway, opened from its site up to RETURN_TTL later (longer than a nonce lives): the payment's random return key (hash_equals below) is the credential, and it only re-reads the invoice from Cryptoway. $id = isset( $_GET['cwpay'] ) ? absint( wp_unslash( $_GET['cwpay'] ) ) : 0; $key = isset( $_GET['cwret'] ) ? sanitize_text_field( wp_unslash( $_GET['cwret'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended $payment = Payment::get( $id ); if ( ! $payment || '' === $key || '' === $payment->return_key() || ! hash_equals( $payment->return_key(), $key ) ) { wp_safe_redirect( home_url( '/' ), 303 ); exit; } // An old way back leads to the button, without the payment: the payer of paid content has the // e-mailed link and "Already paid?". if ( time() - $payment->created_at() > self::RETURN_TTL ) { wp_safe_redirect( Button::page_url( $payment->source_post() ), 303 ); exit; } // The notification has usually arrived; when it has not, one read settles most cases. self::sync_if_stale( $payment ); if ( Button::AFTER_REDIRECT === $payment->after() && '' !== $payment->meta( Payment::META_REDIRECT ) && Payment::STATUS_PAID === $payment->status() ) { self::redirect_to( $payment->meta( Payment::META_REDIRECT ) ); } wp_safe_redirect( self::done_url( $payment ), 303 ); exit; } // ------------------------------------------------------------------ the access link again /** "Already paid?": mail the access link to the e-mail a payment of this button was made with. */ private static function handle_recover(): void { self::no_cache(); if ( ! isset( $_POST['cwbtn_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['cwbtn_nonce'] ) ), self::NONCE_RECOVER ) ) { // Only the way back to the button is read from a refused form. self::expired( isset( $_POST['cwbtn_cfg'] ) ? sanitize_text_field( wp_unslash( $_POST['cwbtn_cfg'] ) ) : '', isset( $_POST['cwbtn_sig'] ) ? sanitize_text_field( wp_unslash( $_POST['cwbtn_sig'] ) ) : '', ! empty( $_POST['cwbtn_ajax'] ) || self::accepts_json() ); } $post = []; foreach ( self::RECOVER_FIELDS as $field ) { if ( isset( $_POST[ $field ] ) ) { $post[ $field ] = sanitize_text_field( wp_unslash( $_POST[ $field ] ) ); } } $wants_json = ! empty( $post['cwbtn_ajax'] ) || self::accepts_json(); $hash = self::button_of( $post ); $result = self::recover( $post ); if ( $wants_json ) { if ( ! $result['ok'] ) { wp_send_json( [ 'ok' => false, 'error' => (string) ( $result['error_text'] ?? self::error_text( $result['error'] ) ), 'code' => $result['error'] ], 400 ); } wp_send_json( [ 'ok' => true, 'text' => self::recover_text() ] ); } // Without JavaScript: back to the button with the notice or the error. $page = Button::page_url( (int) ( $result['post'] ?? 0 ) ); $back = $result['ok'] ? add_query_arg( [ Button::QUERY => 'sent', 'b' => $hash ], $page ) : add_query_arg( [ Button::QUERY => 'error', 'e' => $result['error'], 'b' => $hash ], $page ); wp_safe_redirect( $back . '#cwbtn-' . $hash, 303 ); exit; } /** * Check the request and, when a payment of this button was made with this e-mail and its * access is still open, mail its link again. The answer is the same whether or not such a * payment exists, and the mail goes out after the answer (a scheduled event), so neither the * words nor the time it takes tell anybody who paid. One mail per payment in RECOVER_COOLDOWN, * so the form cannot be used to flood a payer. * * @return array{ok: bool, error: string, payment: ?Payment, post: int, error_text?: string} */ public static function recover( array $post ): array { $cfg = Button::unpack( (string) ( $post['cwbtn_cfg'] ?? '' ), (string) ( $post['cwbtn_sig'] ?? '' ) ); if ( ! $cfg || self::from_another_site() ) { return self::fail( 'button' ); } $source_post = (int) ( $cfg['post'] ?? 0 ); if ( Button::AFTER_CONTENT !== $cfg['after'] ) { return self::fail( 'button', $source_post ); } $email = sanitize_email( (string) ( $post['cwbtn_email'] ?? '' ) ); if ( ! is_email( $email ) ) { return self::fail( 'email', $source_post ); } if ( ! self::within_rate_limit( self::ip_hash(), 'recover', self::RECOVER_LIMIT ) ) { return self::fail( 'limit', $source_post ); } foreach ( Payment::find_by_payer_email( Button::keys( $cfg ), $source_post, $email ) as $payment ) { if ( Button::is_unlocked( $payment ) ) { self::queue_access_mail( $payment ); break; } } return [ 'ok' => true, 'error' => '', 'payment' => null, 'post' => $source_post ]; } /** Send the access link in a scheduled event, at most once per RECOVER_COOLDOWN per payment. */ private static function queue_access_mail( Payment $payment ): void { $gate = 'cryptoway_button_recover_p' . $payment->id(); if ( false !== get_transient( $gate ) ) { return; } set_transient( $gate, 1, self::RECOVER_COOLDOWN ); if ( ! wp_next_scheduled( self::HOOK_ACCESS_MAIL, [ $payment->id() ] ) ) { wp_schedule_single_event( time(), self::HOOK_ACCESS_MAIL, [ $payment->id() ] ); } // Run it now in the background rather than on the next visit; a server cron job runs it otherwise. if ( ! ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) ) { spawn_cron(); } } /** The scheduled event: the access link, if the access is still open. */ public static function send_access_mail( $payment_id ): void { $payment = Payment::get( absint( $payment_id ) ); if ( $payment && Button::is_unlocked( $payment ) ) { Email::access_link( $payment, true ); } } /** What the visitor reads after asking for the link, whether or not a payment was found. */ public static function recover_text(): string { return __( 'If a payment was made with this e-mail, the access link is on its way. Check the spam folder too.', 'cryptoway-button' ); } /** Read the invoice when the last read is older than 20 seconds and the payment is still open. */ public static function sync_if_stale( Payment $payment ): void { if ( ! in_array( $payment->status(), Payment::OPEN, true ) || '' === $payment->invoice_id() || ! Settings::keys_present() ) { return; } $last = (int) $payment->meta( Payment::META_LAST_SYNC ); if ( time() - $last < 20 ) { return; } $manager = new PaymentManager( $payment ); if ( ! $manager->should_recheck() ) { return; } // One read at a time per payment: parallel polls (many tabs, a script) do not each call Cryptoway. $lock = 'cryptoway_button_lock_poll_' . $payment->id(); if ( ! Lock::acquire( $lock, 30 ) ) { return; } try { // Another poll may have read it while this one waited for the lock. $payment->forget_cached_meta(); if ( time() - (int) $payment->meta( Payment::META_LAST_SYNC ) < 20 ) { return; } // Stamped before the call: polls arriving while it runs do not start more calls to Cryptoway. $payment->set_meta( Payment::META_LAST_SYNC, (string) time() ); $manager->sync_status_from_api( 'poll' ); } finally { Lock::release( $lock ); } } /** * Send the visitor to a page on another host: the Cryptoway pay page (its domain was checked when * the invoice was saved) or the thank-you page set in the button's signed parameters. Nothing a * visitor sends can choose the address. */ private static function redirect_to( string $url ): void { $host = wp_parse_url( $url, PHP_URL_HOST ); if ( is_string( $host ) && '' !== $host ) { add_filter( 'allowed_redirect_hosts', static function ( array $hosts ) use ( $host ): array { $hosts[] = $host; return $hosts; } ); } wp_safe_redirect( $url, 303 ); exit; } /** Where Cryptoway sends the payer after the payment page: with the return key, never the view key. */ public static function return_url( Payment $payment ): string { return add_query_arg( [ Button::QUERY => 'return', 'cwpay' => $payment->id(), 'cwret' => $payment->return_key() ], home_url( '/' ) ); } /** The page with the button, showing this payment instead of the form. */ public static function done_url( Payment $payment ): string { return add_query_arg( [ Button::QUERY => 'done', 'cwpay' => $payment->id(), 'cwkey' => $payment->view_key() ], Button::page_url( $payment->source_post() ) ) . '#cwbtn-' . $payment->button(); } // ------------------------------------------------------------------ helpers /** @param string $text A wording for this very case (e.g. with the amount); the generic error_text() otherwise. */ private static function fail( string $code, int $post = 0, string $text = '' ): array { return [ 'ok' => false, 'error' => $code, 'payment' => null, 'post' => $post, 'error_text' => '' !== $text ? $text : self::error_text( $code ) ]; } /** The message a visitor sees for an error code. */ public static function error_text( string $code ): string { return match ( $code ) { 'button' => __( 'This button is out of date. Please reload the page and try again.', 'cryptoway-button' ), 'keys' => __( 'Crypto payments are not set up on this site yet.', 'cryptoway-button' ), 'amount' => __( 'Please enter an amount.', 'cryptoway-button' ), 'min' => __( 'The amount is below the smallest payment accepted.', 'cryptoway-button' ), 'coin' => __( 'This coin cannot be used right now. Please choose another one.', 'cryptoway-button' ), 'email' => __( 'Please check the e-mail address.', 'cryptoway-button' ), 'email_required' => __( 'Please enter your e-mail: the access link is sent there.', 'cryptoway-button' ), 'limit' => __( 'Too many payment attempts. Please wait a few minutes and try again.', 'cryptoway-button' ), 'api' => __( 'The payment could not be started right now. Please try again in a minute.', 'cryptoway-button' ), 'expired' => __( 'This page was open for too long. Please try again.', 'cryptoway-button' ), default => '', }; } private static function default_title(): string { /* translators: %s: site name */ return sprintf( __( 'Payment at %s', 'cryptoway-button' ), wp_specialchars_decode( (string) get_option( 'blogname' ), ENT_QUOTES ) ); } /** * A keyed hash of the visitor's address, for the rate limits only: enough to tell visitors * apart, not an address on file. */ private static function ip_hash(): string { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; /** * The visitor's address the rate limits count by. Behind a proxy or a CDN that does not * restore the visitor's address, every visitor shares the proxy's: a site there can return * the real one from the header its proxy sets (only a header that proxy always overwrites). * * @param string $ip REMOTE_ADDR. */ $ip = (string) apply_filters( 'cryptoway_button_client_ip', $ip ); // One IPv6 network (/64) is one visitor: it holds more addresses than any limit could count. $packed = str_contains( $ip, ':' ) ? inet_pton( $ip ) : false; if ( false !== $packed && 16 === strlen( $packed ) ) { $ip = bin2hex( substr( $packed, 0, 8 ) ) . '::/64'; } return substr( hash_hmac( 'sha256', $ip, wp_salt( 'auth' ) ), 0, 32 ); } /** * Count a submission; false when this visitor made too many of this kind lately. The count is read * and written under a lock: parallel presses would otherwise all read the same number. */ private static function within_rate_limit( string $ip_hash, string $kind = '', int $limit = self::RATE_LIMIT ): bool { $key = 'cryptoway_button_rate_' . ( '' !== $kind ? $kind . '_' : '' ) . $ip_hash; return (bool) Lock::run( 'cryptoway_button_lock_rate', static function () use ( $key, $limit ): bool { $now = time(); $state = get_transient( $key ); // A fixed window from its first count: later counts must not push its end back, or a // steadily busy site would never get its count back to zero. if ( ! is_array( $state ) || (int) ( $state['t'] ?? 0 ) + self::RATE_WINDOW <= $now ) { $state = [ 'n' => 0, 't' => $now ]; } if ( (int) $state['n'] >= $limit ) { return false; } $state['n'] = (int) $state['n'] + 1; set_transient( $key, $state, max( 1, (int) $state['t'] + self::RATE_WINDOW - $now ) ); return true; }, 5, 1000 ); } /** * For an admin trying the button: the visitor's text and why Cryptoway refused (keys, Allowed IPs); * '' for everyone else, who gets the plain text only. */ private static function for_admin( string $code ): string { if ( '' === self::$why || ! current_user_can( 'manage_options' ) ) { return ''; } /* translators: 1: the message a visitor sees, 2: the reason Cryptoway gave */ return sprintf( __( '%1$s Only you see this: %2$s', 'cryptoway-button' ), self::error_text( $code ), self::$why ); } /** How many new invoices the whole site may start in RATE_WINDOW seconds. */ private static function site_limit(): int { /** * New invoices the whole site may start in ten minutes, whoever presses: a page elsewhere can * make its visitors' browsers press the button. Raise it for a very busy campaign. * * @param int $limit 300 by default. */ return max( 1, (int) apply_filters( 'cryptoway_button_site_limit', self::SITE_LIMIT ) ); } /** * Whether the form was posted by a page on another site (a page that makes its visitors' * browsers start invoices or ask for access links here). Browsers say so in Sec-Fetch-Site; older * ones only send Origin. */ private static function from_another_site(): bool { $fetch = isset( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_FETCH_SITE'] ) ) ) : ''; $origin = isset( $_SERVER['HTTP_ORIGIN'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) : ''; $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; if ( '' !== $fetch ) { $foreign = 'cross-site' === $fetch; } elseif ( '' === $origin ) { $foreign = false; } else { $from = strtolower( (string) wp_parse_url( $origin, PHP_URL_HOST ) ); $own = array_map( 'strtolower', array_filter( [ (string) wp_parse_url( home_url(), PHP_URL_HOST ), (string) wp_parse_url( site_url(), PHP_URL_HOST ), (string) preg_replace( '/:\d+$/', '', $host ), ] ) ); $foreign = '' === $from || ! in_array( $from, $own, true ); } /** * Whether to refuse a form posted from another site. Return false to accept it (a page shown * through a translation proxy, for example). * * @param bool $foreign */ return (bool) apply_filters( 'cryptoway_button_refuse_foreign_post', $foreign ); } /** Whether a pay_url is on a Cryptoway domain. */ public static function pay_url_is_trusted( string $url ): bool { $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) ) { return false; } /** * URL schemes a pay page may use. Only https for a store; a test stand adds http. * * @param string[] $schemes */ $schemes = (array) apply_filters( 'cryptoway_button_pay_url_schemes', [ 'https' ] ); if ( ! in_array( strtolower( (string) ( $parts['scheme'] ?? '' ) ), $schemes, true ) ) { return false; } $host = strtolower( (string) ( $parts['host'] ?? '' ) ); /** * Domains a payer may be sent to. Test stands add their own; a store never needs to. * * @param string[] $domains */ $domains = (array) apply_filters( 'cryptoway_button_pay_url_domains', self::PAY_URL_DOMAINS ); foreach ( $domains as $domain ) { if ( $host === $domain || str_ends_with( $host, '.' . $domain ) ) { return true; } } return false; } }