From 25dfaffc453a41f9e55ec0f34d77fe8400ced774 Mon Sep 17 00:00:00 2001 From: xecdev Date: Mon, 24 Nov 2025 21:03:35 +0430 Subject: [PATCH 1/6] =?UTF-8?q?Secure=20=E2=80=9CLogin=20via=20Cashtab?= =?UTF-8?q?=E2=80=9D=20Flow=20(Server-Verified=20Login=20Tokens)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/js/paybutton-paywall-cashtab-login.js | 56 +++++-- includes/class-paybutton-activator.php | 32 +++- includes/class-paybutton-ajax.php | 147 +++++++++++++++++-- paybutton.php | 4 + 4 files changed, 212 insertions(+), 27 deletions(-) diff --git a/assets/js/paybutton-paywall-cashtab-login.js b/assets/js/paybutton-paywall-cashtab-login.js index c6f7ea0..7779ba1 100644 --- a/assets/js/paybutton-paywall-cashtab-login.js +++ b/assets/js/paybutton-paywall-cashtab-login.js @@ -3,16 +3,18 @@ let isLoggedIn = false; /** * Handle user login: - * Called when the PayButton payment returns a successful login transaction. - */ -function handleLogin(address) { + * Called when the PayButton login flow completes successfully. +*/ +function handleLogin(address, txHash, loginToken) { isLoggedIn = true; jQuery.post( PaywallAjax.ajaxUrl, { action: 'paybutton_save_address', security: PaywallAjax.nonce, - address: address + address: address, + tx_hash: txHash, + login_token: loginToken }, function() { var baseUrl = location.href.split('?')[0]; @@ -26,7 +28,7 @@ function handleLogin(address) { /** * Handle user logout. - */ +*/ function handleLogout() { jQuery.post( PaywallAjax.ajaxUrl, @@ -44,10 +46,12 @@ function handleLogout() { /** * Render the "Login via Cashtab" PayButton. * (5.5 XEC is hard-coded.) - */ +*/ function renderLoginPaybutton() { // Shared state: login address captured in onSuccess, consumed in onClose. let loginAddr = null; + let loginTx = null; + PayButton.render(document.getElementById('loginPaybutton'), { to: PaywallAjax.defaultAddress, amount: 5.5, @@ -56,15 +60,49 @@ function renderLoginPaybutton() { hoverText: 'Click to Login', successText: 'Login Successful!', autoClose: true, + opReturn: 'login', onSuccess: function (tx) { loginAddr = tx?.inputAddresses?.[0] ?? null; + loginTx = { + hash: tx?.hash ?? '', + timestamp: tx?.timestamp ?? 0 + }; }, onClose: function () { - if (loginAddr) { - handleLogin(loginAddr); + if (loginAddr && loginTx && loginTx.hash) { + // Make stable copies for the whole retry flow + const addrCopy = loginAddr; + const hashCopy = loginTx.hash; + + function tryValidateLogin(attempt) { + jQuery.post( + PaywallAjax.ajaxUrl, + { + action: 'validate_login_tx', + security: PaywallAjax.nonce, + wallet_address: addrCopy, + tx_hash: hashCopy + }, + function (resp) { + if (resp && resp.success && resp.data && resp.data.login_token) { + // Pass the random token from the server + handleLogin(addrCopy, hashCopy, resp.data.login_token); + } else { + if (attempt === 1) { + // Retry once again after 3 seconds + setTimeout(() => tryValidateLogin(2), 3000); + } else { + alert('⚠️ Login failed: Invalid or expired transaction.'); + } + } + } + ); + } + tryValidateLogin(1); } - // Prevent stale reuse on subsequent opens + // Safe to clear shared state (the flow above uses the copies) loginAddr = null; + loginTx = null; } }); } diff --git a/includes/class-paybutton-activator.php b/includes/class-paybutton-activator.php index 86cbac9..5f94921 100644 --- a/includes/class-paybutton-activator.php +++ b/includes/class-paybutton-activator.php @@ -13,7 +13,7 @@ class PayButton_Activator { /** * Activation hook callback. - */ + */ public static function activate() { self::create_tables(); self::create_profile_page(); @@ -34,21 +34,20 @@ private static function migrate_old_option() { /** * Create the custom table for unlocked content. - */ + */ public static function create_tables() { global $wpdb; //$wpdb is WordPress’s way of interacting with the database, and it provides methods for running queries and getting the correct table prefix. - - $table_name = $wpdb->prefix . 'paybutton_paywall_unlocked'; $charset_collate = $wpdb->get_charset_collate(); - // Updated table definition with the new column name pb_paywall_user_wallet_address - // and index name pb_paywall_user_wallet_address_idx + // ---- PayButton Paywall Unlocks table ---- + $table_name = $wpdb->prefix . 'paybutton_paywall_unlocked'; + $sql = "CREATE TABLE IF NOT EXISTS $table_name ( id INT NOT NULL AUTO_INCREMENT, pb_paywall_user_wallet_address VARCHAR(255) NOT NULL, post_id BIGINT(20) UNSIGNED NOT NULL, tx_hash VARCHAR(64) DEFAULT '', - tx_amount DECIMAL(20,8) DEFAULT 0, + tx_amount DECIMAL(20,2) DEFAULT 0, tx_timestamp DATETIME DEFAULT '0000-00-00 00:00:00', is_logged_in TINYINT(1) DEFAULT 0, PRIMARY KEY (id), @@ -59,6 +58,25 @@ public static function create_tables() { require_once( ABSPATH . 'wp-admin/includes/upgrade.php' ); dbDelta( $sql ); + // ---- PayButton Logins table ---- + $login_table = $wpdb->prefix . 'paybutton_logins'; + + $sql_login = "CREATE TABLE IF NOT EXISTS $login_table ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + wallet_address VARCHAR(255) NOT NULL, + tx_hash VARCHAR(64) NOT NULL, + tx_amount DECIMAL(20,2) NOT NULL, + tx_timestamp INT(11) NOT NULL, + login_token VARCHAR(64) DEFAULT '', + used TINYINT(1) NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + KEY tx_hash_idx (tx_hash), + KEY wallet_addr_idx (wallet_address(190)), + KEY used_idx (used), + KEY login_token_idx (login_token) + ) {$charset_collate};"; + + dbDelta( $sql_login ); } /** diff --git a/includes/class-paybutton-ajax.php b/includes/class-paybutton-ajax.php index 63e2805..92952ee 100644 --- a/includes/class-paybutton-ajax.php +++ b/includes/class-paybutton-ajax.php @@ -43,6 +43,9 @@ public function __construct() { add_action( 'wp_ajax_fetch_unlocked_content', array( $this, 'fetch_unlocked_content' ) ); add_action( 'wp_ajax_nopriv_fetch_unlocked_content', array( $this, 'fetch_unlocked_content' ) ); + // AJAX endpoint to validate a login transaction (marking it as "used" after consumption) + add_action('wp_ajax_validate_login_tx', array($this, 'ajax_validate_login_tx')); + add_action('wp_ajax_nopriv_validate_login_tx', array($this, 'ajax_validate_login_tx')); } /** * Payment Trigger Handler with Cryptographic Verification @@ -77,6 +80,8 @@ public function payment_trigger() { $json = json_decode( $raw_post_data, true ); + // error_log('[paybutton] payment_trigger hit'); + if ( ! is_array( $json ) ) { wp_send_json_error( array( 'message' => 'Malformed JSON.' ), 400 ); return; @@ -88,7 +93,7 @@ public function payment_trigger() { $tx_hash_raw = $json['tx_hash'] ?? ''; $tx_amount_raw = $json['tx_amount'] ?? ''; $ts_raw = $json['tx_timestamp'] ?? 0; - $user_addr_raw = $json['user_address'][0] ?? ''; + $user_addr_raw = $json['user_address'][0]['address'] ?? ($json['user_address'][0] ?? ''); unset( $json ); // discard the rest immediately @@ -110,6 +115,7 @@ public function payment_trigger() { wp_send_json_error(['message' => 'Signature verification failed.']); return; } + // error_log('[paybutton] signature ok'); //Sanitize data $post_id = intval( $post_id_raw ); @@ -117,7 +123,53 @@ public function payment_trigger() { $tx_amount = sanitize_text_field( $tx_amount_raw ); $tx_timestamp = intval( $ts_raw ); $user_address = sanitize_text_field( $user_addr_raw ); - + // error_log('[paybutton] rawMessage=' . print_r($post_id_raw, true)); + /** + * If PayButton OP_RETURN (carried via post_id.rawMessage) indicates a login flow, + * skip unlock logic and just record a login tx row. + * This short-circuits the rest of payment_trigger() when it’s a login payment, + * so it won’t run the normal unlock write path. + * TODO: Rename the post_id field to "opReturn" to avoid confusion. + */ + if ( is_string( $post_id_raw ) && stripos( $post_id_raw, 'login' ) !== false ) { + if ( empty( $user_address ) || empty( $tx_hash ) || empty( $tx_timestamp ) ) { + wp_send_json_error(['message' => 'Missing login tx fields.'], 400); + return; + } + + global $wpdb; + $login_table = $wpdb->prefix . 'paybutton_logins'; + + // Idempotency: avoid dupes on replays + $exists = $wpdb->get_var( $wpdb->prepare( + "SELECT id FROM {$login_table} WHERE wallet_address = %s AND tx_hash = %s LIMIT 1", + $user_address, $tx_hash + ) ); + error_log('[paybutton] login-branch addr=' . $user_address . ' tx=' . $tx_hash . ' ts=' . $tx_timestamp); + if ( ! $exists ) { + $wpdb->insert( + $login_table, + array( + 'wallet_address' => $user_address, + 'tx_hash' => $tx_hash, + 'tx_amount' => (float) $tx_amount, + 'tx_timestamp' => (int) $tx_timestamp, + 'used' => 0, + ), + array('%s','%s','%f','%d','%d') + ); + } + + if ($wpdb->last_error) { + error_log('[paybutton] insert error: ' . $wpdb->last_error); + } else { + error_log('[paybutton] insert ok id=' . $wpdb->insert_id); + } + + wp_send_json_success(['message' => 'Login tx recorded']); + global $wpdb; + return; + } // Convert timestamp to MySQL datetime $mysql_timestamp = $tx_timestamp ? gmdate('Y-m-d H:i:s', $tx_timestamp) : '0000-00-00 00:00:00'; @@ -173,17 +225,35 @@ private function verify_signature($payload, $signature, $public_key_hex) { } } /** - * The following function saves the 'logged in via PayButton' user's wallet address - * in a variable called pb_paywall_user_wallet_address from the handleLogin() method - * of the "paybutton-paywall-cashtab-login.js" file via AJAX. - * - * This function verifies the AJAX nonce for security, - * sanitizes the 'address' field from the POST data, and then stores it in - * a cookie (lasting a week). + * The following function sets the user's wallet address in a cookie via AJAX after + * a successful login transaction. */ public function save_address() { check_ajax_referer( 'paybutton_paywall_nonce', 'security' ); - $address = sanitize_text_field( $_POST['address'] ); + $address = sanitize_text_field( $_POST['address'] ?? '' ); + $tx_hash = sanitize_text_field( $_POST['tx_hash'] ?? '' ); + $login_token = sanitize_text_field( $_POST['login_token'] ?? '' ); + + if (!$address || !$tx_hash || !$login_token) { + wp_send_json_error(['message' => 'Missing address, tx_hash, or login_token']); + } + + // Find the specific validated row for this token + wallet address + tx hash + global $wpdb; + $login_table = $wpdb->prefix . 'paybutton_logins'; + $row = $wpdb->get_row($wpdb->prepare( + "SELECT id FROM {$login_table} + WHERE wallet_address = %s + AND tx_hash = %s + AND login_token = %s + AND used = 1 + LIMIT 1", + $address, $tx_hash, $login_token + )); + + if (!$row) { + wp_send_json_error(['message' => 'No validated login found for this token']); + } // Retrieve the blacklist and check the address $blacklist = get_option( 'paybutton_blacklist', array() ); @@ -193,7 +263,8 @@ public function save_address() { } // blacklist End - PayButton_State::set_address( $address ); wp_send_json_success(); + PayButton_State::set_address( $address ); + wp_send_json_success(); } /** @@ -394,4 +465,58 @@ private function store_unlock_in_db( $address, $post_id, $tx_hash, $tx_amount, $ array( '%s', '%d', '%s', '%f', '%s', '%d' ) ); } + + /** + * AJAX endpoint to validate a login transaction. + * This checks that the provided wallet address and tx hash correspond to + * an unused login transaction. If valid, it generates and attaches a login token + * and marks the transaction as used to prevent replay. + */ + public function ajax_validate_login_tx() { + check_ajax_referer('paybutton_paywall_nonce', 'security'); + + $wallet_address = sanitize_text_field($_POST['wallet_address'] ?? ''); + $tx_hash = sanitize_text_field($_POST['tx_hash'] ?? ''); + + if (empty($wallet_address) || empty($tx_hash)) { + wp_send_json_error('Missing data'); + } + + global $wpdb; + $table = $wpdb->prefix . 'paybutton_logins'; + + // Only accept unused login tx rows + $row = $wpdb->get_row($wpdb->prepare( + "SELECT id FROM {$table} + WHERE wallet_address = %s AND tx_hash = %s AND used = 0 + ORDER BY id DESC LIMIT 1", + $wallet_address, $tx_hash + )); + + if (!$row) { + wp_send_json_error('Login validation failed'); // no match or already used + } + + // Generate a random, unguessable token like "9fx0..._..." so that malicious actors + // can't fake login attempts by reusing the same wallet address + tx hash using fake + // AJAX calls from the browser. + $raw = random_bytes(18); // 18 bytes → ~24 chars base64url + $token = rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + + // Mark as used + attach token + $wpdb->update( + $table, + array( + 'used' => 1, + 'login_token' => $token, + ), + array('id' => (int)$row->id), + array('%d','%s'), + array('%d') + ); + + wp_send_json_success(array( + 'login_token' => $token, + )); + } } \ No newline at end of file diff --git a/paybutton.php b/paybutton.php index 6dd350a..0c8b619 100644 --- a/paybutton.php +++ b/paybutton.php @@ -46,6 +46,10 @@ // Initialize plugin functionality. add_action( 'plugins_loaded', function() { + // Make sure tables (including any newly added ones) exist after upgrades. + if ( class_exists( 'PayButton_Activator' ) ) { + PayButton_Activator::create_tables(); + } // Initialize admin functionality if in admin area. if ( is_admin() ) { new PayButton_Admin(); From 71d2f9e96cacbb937ff88cb83c4077fa9866c1a1 Mon Sep 17 00:00:00 2001 From: xecdev Date: Tue, 25 Nov 2025 20:31:47 +0430 Subject: [PATCH 2/6] =?UTF-8?q?Secure=20=E2=80=9CContent=20Unlocking?= =?UTF-8?q?=E2=80=9D=20Flow=20(Server-Verified=20Login=20Tokens)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/js/paywalled-content.js | 152 +++++++++++++++++-------- includes/class-paybutton-activator.php | 13 ++- includes/class-paybutton-ajax.php | 142 ++++++++++++++++++----- 3 files changed, 229 insertions(+), 78 deletions(-) diff --git a/assets/js/paywalled-content.js b/assets/js/paywalled-content.js index 912dd7d..e3e6903 100644 --- a/assets/js/paywalled-content.js +++ b/assets/js/paywalled-content.js @@ -24,66 +24,57 @@ jQuery(document).ready(function($) { $('.paybutton-container').each(function() { var $container = $(this); var configData = $container.data('config'); + if (typeof configData === 'string') { try { configData = JSON.parse(configData); - } catch(e) { + } catch (e) { console.error('Invalid JSON in paybutton-container data-config'); return; } } - configData.onSuccess = function(tx) { - $.ajax({ + + // Shared state: user wallet add + unlock tx captured in onSuccess, consumed in onClose. + let unlockAddr = null; + let unlockTx = null; + + // Helper to fetch and inject unlocked content + function fetchUnlocked() { + jQuery.ajax({ method: 'POST', url: PaywallAjax.ajaxUrl, data: { - action: 'mark_payment_successful', + action: 'fetch_unlocked_content', post_id: configData.postId, - security: PaywallAjax.nonce, - tx_hash: tx.hash || '', - tx_amount: tx.amount || '', - tx_timestamp: tx.timestamp || '', - // NEW: Pass the first input address to store in the DB even for non-logged-in users - user_address: (tx.inputAddresses && tx.inputAddresses.length > 0) ? tx.inputAddresses[0] : '', - autoClose: configData.autoClose + security: PaywallAjax.nonce }, - success: function () { - setTimeout(function () { - jQuery.ajax({ - method: 'POST', - url: PaywallAjax.ajaxUrl, - data: { - action: 'fetch_unlocked_content', - post_id: configData.postId, - security: PaywallAjax.nonce - }, - success: function (resp) { - if (resp && resp.success) { - // 1) Replace only the paywalled block content - var $wrapper = jQuery('#pb-paywall-' + configData.postId); - if ($wrapper.length && resp.data.unlocked_html) { - $wrapper.html(resp.data.unlocked_html); - } + success: function (resp) { + if (resp && resp.success) { + var $wrapper = jQuery('#pb-paywall-' + configData.postId); + if ($wrapper.length && resp.data.unlocked_html) { + $wrapper.html(resp.data.unlocked_html); + } - // Optional scroll-to-unlocked-content-indicator + Cache Busting Mechanism - var baseUrl = location.href.split('#')[0].split('?')[0]; - var newUrl = baseUrl + '?t=' + Date.now() + '#unlocked'; - window.history.replaceState(null, '', newUrl); + // Cache bust + scroll to unlocked content indicator + var baseUrl = location.href.split('#')[0].split('?')[0]; + var newUrl = baseUrl + '?t=' + Date.now() + '#unlocked'; + window.history.replaceState(null, '', newUrl); - if (PaywallAjax.scrollToUnlocked === '1' || PaywallAjax.scrollToUnlocked === 1) { - var $target = jQuery('#unlocked'); - if ($target.length) { - var headerOffset = 80; - jQuery('html, body').animate({ scrollTop: $target.offset().top - headerOffset }, 500); - } - } - } + if (PaywallAjax.scrollToUnlocked === '1' || PaywallAjax.scrollToUnlocked === 1) { + var $target = jQuery('#unlocked'); + if ($target.length) { + var headerOffset = 80; + jQuery('html, body').animate({ scrollTop: $target.offset().top - headerOffset }, 500); } - }); - }, 20); // Slight delay to ensure DB/cookie update is processed before fetching content + } + } } }); - }; + } + + // Configure the PayButton like before, but: + // - onSuccess only captures tx data + // - onClose does the secure validate -> mark_payment_successful -> fetch flow PayButton.render($container[0], { to: configData.to, amount: configData.amount, @@ -91,10 +82,79 @@ jQuery(document).ready(function($) { text: configData.buttonText, hoverText: configData.hoverText, successText: configData.successText, - onSuccess: configData.onSuccess, theme: configData.theme, - opReturn: configData.opReturn, //This is a hack to give the PB server the post ID to send it back to WP's DB - autoClose: configData.autoClose + opReturn: configData.opReturn, // carries postID + autoClose: configData.autoClose, + + onSuccess: function (tx) { + unlockAddr = (tx.inputAddresses && tx.inputAddresses.length > 0) + ? tx.inputAddresses[0] + : ''; + unlockTx = { + hash: tx.hash || '', + amount: tx.amount || '', + timestamp: tx.timestamp || 0 + }; + }, + + onClose: function () { + if (unlockAddr && unlockTx && unlockTx.hash) { + const addrCopy = unlockAddr; + const hashCopy = unlockTx.hash; + const amtCopy = unlockTx.amount; + const tsCopy = unlockTx.timestamp; + const postIdCopy = configData.postId; + + function tryValidateUnlock(attempt) { + jQuery.post( + PaywallAjax.ajaxUrl, + { + action: 'validate_unlock_tx', + security: PaywallAjax.nonce, + wallet_address: addrCopy, + tx_hash: hashCopy, + post_id: postIdCopy + }, + function (resp) { + if (resp && resp.success && resp.data && resp.data.unlock_token) { + // We have a server-issued token – now mark payment as successful. + jQuery.ajax({ + method: 'POST', + url: PaywallAjax.ajaxUrl, + data: { + action: 'mark_payment_successful', + post_id: postIdCopy, + security: PaywallAjax.nonce, + tx_hash: hashCopy, + tx_amount: amtCopy, + tx_timestamp: tsCopy, + user_address: addrCopy, + unlock_token: resp.data.unlock_token + }, + success: function () { + // Finally, fetch and render the unlocked content + fetchUnlocked(); + } + }); + } else { + if (attempt === 1) { + // Retry once after brief delay + setTimeout(function () { tryValidateUnlock(2); }, 3000); + } else { + alert('⚠️ Payment could not be verified on-chain. Please try again.'); + } + } + } + ); + } + + tryValidateUnlock(1); + } + + // Safe to clear shared state (the flow above uses the copies) + unlockAddr = null; + unlockTx = null; + } }); }); }); diff --git a/includes/class-paybutton-activator.php b/includes/class-paybutton-activator.php index 5f94921..06ec1c0 100644 --- a/includes/class-paybutton-activator.php +++ b/includes/class-paybutton-activator.php @@ -42,7 +42,7 @@ public static function create_tables() { // ---- PayButton Paywall Unlocks table ---- $table_name = $wpdb->prefix . 'paybutton_paywall_unlocked'; - $sql = "CREATE TABLE IF NOT EXISTS $table_name ( + $sql = "CREATE TABLE $table_name ( id INT NOT NULL AUTO_INCREMENT, pb_paywall_user_wallet_address VARCHAR(255) NOT NULL, post_id BIGINT(20) UNSIGNED NOT NULL, @@ -50,9 +50,13 @@ public static function create_tables() { tx_amount DECIMAL(20,2) DEFAULT 0, tx_timestamp DATETIME DEFAULT '0000-00-00 00:00:00', is_logged_in TINYINT(1) DEFAULT 0, + unlock_token VARCHAR(64) DEFAULT '', + used TINYINT(1) NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY pb_paywall_user_wallet_address_idx (pb_paywall_user_wallet_address), - KEY post_id_idx (post_id) + KEY post_id_idx (post_id), + KEY tx_hash_idx (tx_hash), + KEY unlock_token_idx (unlock_token) ) $charset_collate;"; require_once( ABSPATH . 'wp-admin/includes/upgrade.php' ); @@ -61,8 +65,8 @@ public static function create_tables() { // ---- PayButton Logins table ---- $login_table = $wpdb->prefix . 'paybutton_logins'; - $sql_login = "CREATE TABLE IF NOT EXISTS $login_table ( - id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + $sql_login = "CREATE TABLE $login_table ( + id INT NOT NULL AUTO_INCREMENT, wallet_address VARCHAR(255) NOT NULL, tx_hash VARCHAR(64) NOT NULL, tx_amount DECIMAL(20,2) NOT NULL, @@ -70,6 +74,7 @@ public static function create_tables() { login_token VARCHAR(64) DEFAULT '', used TINYINT(1) NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), KEY tx_hash_idx (tx_hash), KEY wallet_addr_idx (wallet_address(190)), KEY used_idx (used), diff --git a/includes/class-paybutton-ajax.php b/includes/class-paybutton-ajax.php index 92952ee..06a9c3e 100644 --- a/includes/class-paybutton-ajax.php +++ b/includes/class-paybutton-ajax.php @@ -46,6 +46,10 @@ public function __construct() { // AJAX endpoint to validate a login transaction (marking it as "used" after consumption) add_action('wp_ajax_validate_login_tx', array($this, 'ajax_validate_login_tx')); add_action('wp_ajax_nopriv_validate_login_tx', array($this, 'ajax_validate_login_tx')); + + // AJAX endpoint to validate an unlock transaction + add_action('wp_ajax_validate_unlock_tx', array($this, 'ajax_validate_unlock_tx')); + add_action('wp_ajax_nopriv_validate_unlock_tx', array($this, 'ajax_validate_unlock_tx')); } /** * Payment Trigger Handler with Cryptographic Verification @@ -293,40 +297,63 @@ public function mark_payment_successful() { $tx_timestamp = isset( $_POST['tx_timestamp'] ) ? sanitize_text_field( $_POST['tx_timestamp'] ) : ''; // NEW: Address passed from front-end if user is not logged in $user_address = isset( $_POST['user_address'] ) ? sanitize_text_field( $_POST['user_address'] ) : ''; + $unlock_token = isset( $_POST['unlock_token'] ) ? sanitize_text_field( $_POST['unlock_token'] ) : ''; + + if ( $post_id <= 0 || empty( $tx_hash ) || empty( $user_address ) || empty( $unlock_token ) ) { + wp_send_json_error( array( 'message' => 'Missing required payment fields.' ), 400 ); + } $mysql_timestamp = '0000-00-00 00:00:00'; if ( is_numeric( $tx_timestamp ) ) { $mysql_timestamp = gmdate( 'Y-m-d H:i:s', intval( $tx_timestamp ) ); } - if ( $post_id > 0 ) { - // Mark this post as "unlocked" in the cookie - PayButton_State::add_article( $post_id ); - - // Determine if user was "logged in" (i.e., cookie has a stored user wallet address) - $is_logged_in = PayButton_State::get_address() ? 1 : 0; - - // Decide which address to store: - $address_to_store = $is_logged_in ? sanitize_text_field( PayButton_State::get_address() ) : $user_address; - - // If we have any address to store, insert a record - if ( ! empty( $address_to_store ) ) { - // Check blacklist again in case user isn't logged in - $blacklist = get_option( 'paybutton_blacklist', array() ); - if ( in_array( $address_to_store, $blacklist ) ) { - wp_send_json_error( array( 'message' => 'This wallet address is blocked.' ) ); - return; - } - - $this->store_unlock_in_db( - $address_to_store, - $post_id, - $tx_hash, - $tx_amount, - $mysql_timestamp, - $is_logged_in - ); - } + // Verify that this token corresponds to a row inserted by the signed webhook in Payment_Trigger(). + global $wpdb; + $table = $wpdb->prefix . 'paybutton_paywall_unlocked'; + + $row = $wpdb->get_row( $wpdb->prepare( + "SELECT id FROM {$table} + WHERE pb_paywall_user_wallet_address = %s + AND post_id = %d + AND tx_hash = %s + AND unlock_token = %s + AND used = 1 + LIMIT 1", + $user_address, + $post_id, + $tx_hash, + $unlock_token + ) ); + + if ( ! $row ) { + wp_send_json_error( array( 'message' => 'No validated unlock found for this token.' ), 403 ); + } + + // At this point, we know: + // - PayButton webhook inserted the row (signature verified in payment_trigger) in DB + // - ajax_validate_unlock_tx() validated it and attached unlock_token + // - This mark_payment_successful call has the same addr+tx+post+token + + // Check blacklist before unlocking + $blacklist = get_option( 'paybutton_blacklist', array() ); + if ( in_array( $user_address, $blacklist, true ) ) { + wp_send_json_error( array( 'message' => 'This wallet address is blocked.' ) ); + } + + // Mark this post as "unlocked" in the cookie for this browser session + PayButton_State::add_article( $post_id ); + + // If the user is logged in via Cashtab login cookie, mark is_logged_in for this row in DB. + $login_addr = sanitize_text_field(PayButton_State::get_address()); + if ( $login_addr && $login_addr === $user_address ) { + $wpdb->update( + $table, + array( 'is_logged_in' => 1 ), + array( 'id' => (int) $row->id ), + array( '%d' ), + array( '%d' ) + ); } wp_send_json_success(); } @@ -519,4 +546,63 @@ public function ajax_validate_login_tx() { 'login_token' => $token, )); } + + /** + * AJAX endpoint to validate a content–unlock transaction. + * This checks that the provided wallet address + tx hash + post_id + * correspond to an unused unlock row created by the signed webhook in payment_trigger(). + * If valid, it generates a random unlock_token, attaches it, and marks the row used. + */ + public function ajax_validate_unlock_tx() { + check_ajax_referer('paybutton_paywall_nonce', 'security'); + + $wallet_address = sanitize_text_field($_POST['wallet_address'] ?? ''); + $tx_hash = sanitize_text_field($_POST['tx_hash'] ?? ''); + $post_id = isset($_POST['post_id']) ? (int) $_POST['post_id'] : 0; + + if (empty($wallet_address) || empty($tx_hash) || $post_id <= 0) { + wp_send_json_error('Missing data'); + } + + global $wpdb; + $table = $wpdb->prefix . 'paybutton_paywall_unlocked'; + + // Only accept unused unlock rows matching this wallet + tx + post + $row = $wpdb->get_row($wpdb->prepare( + "SELECT id FROM {$table} + WHERE pb_paywall_user_wallet_address = %s + AND tx_hash = %s + AND post_id = %d + AND used = 0 + ORDER BY id DESC + LIMIT 1", + $wallet_address, + $tx_hash, + $post_id + )); + + if (!$row) { + wp_send_json_error('Unlock validation failed'); // no match or already used + } + + // Generate a random, unguessable token + $raw = random_bytes(18); // ~24 chars base64url + $token = rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); + + // Mark row as used + attach unlock token + $wpdb->update( + $table, + array( + 'used' => 1, + 'unlock_token' => $token, + ), + array( 'id' => (int) $row->id ), + array( '%d', '%s' ), + array( '%d' ) + ); + + wp_send_json_success(array( + 'unlock_token' => $token, + )); + } } \ No newline at end of file From 9fb134c564f090843b3fedc2bb974deb594b9541 Mon Sep 17 00:00:00 2001 From: xecdev Date: Tue, 25 Nov 2025 20:46:22 +0430 Subject: [PATCH 3/6] responding to bot's feedback: remove unused timestamp code --- includes/class-paybutton-ajax.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/includes/class-paybutton-ajax.php b/includes/class-paybutton-ajax.php index 06a9c3e..43cd2ac 100644 --- a/includes/class-paybutton-ajax.php +++ b/includes/class-paybutton-ajax.php @@ -303,11 +303,6 @@ public function mark_payment_successful() { wp_send_json_error( array( 'message' => 'Missing required payment fields.' ), 400 ); } - $mysql_timestamp = '0000-00-00 00:00:00'; - if ( is_numeric( $tx_timestamp ) ) { - $mysql_timestamp = gmdate( 'Y-m-d H:i:s', intval( $tx_timestamp ) ); - } - // Verify that this token corresponds to a row inserted by the signed webhook in Payment_Trigger(). global $wpdb; $table = $wpdb->prefix . 'paybutton_paywall_unlocked'; From a4399bf91263b2a4282f2b72b7d0d5d246da4516 Mon Sep 17 00:00:00 2001 From: xecdev Date: Wed, 26 Nov 2025 08:12:41 +0430 Subject: [PATCH 4/6] remove duplicate global variable --- includes/class-paybutton-ajax.php | 1 - 1 file changed, 1 deletion(-) diff --git a/includes/class-paybutton-ajax.php b/includes/class-paybutton-ajax.php index 43cd2ac..176ab4d 100644 --- a/includes/class-paybutton-ajax.php +++ b/includes/class-paybutton-ajax.php @@ -171,7 +171,6 @@ public function payment_trigger() { } wp_send_json_success(['message' => 'Login tx recorded']); - global $wpdb; return; } // Convert timestamp to MySQL datetime From 9742ef7efadc9f691aa9c662c7a04d4f763b1c7f Mon Sep 17 00:00:00 2001 From: xecdev Date: Wed, 26 Nov 2025 08:14:40 +0430 Subject: [PATCH 5/6] remove trailing space and change add to address --- assets/js/paywalled-content.js | 2 +- includes/class-paybutton-ajax.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/js/paywalled-content.js b/assets/js/paywalled-content.js index e3e6903..65fcb84 100644 --- a/assets/js/paywalled-content.js +++ b/assets/js/paywalled-content.js @@ -34,7 +34,7 @@ jQuery(document).ready(function($) { } } - // Shared state: user wallet add + unlock tx captured in onSuccess, consumed in onClose. + // Shared state: user wallet address + unlock tx captured in onSuccess, consumed in onClose. let unlockAddr = null; let unlockTx = null; diff --git a/includes/class-paybutton-ajax.php b/includes/class-paybutton-ajax.php index 176ab4d..95c8a51 100644 --- a/includes/class-paybutton-ajax.php +++ b/includes/class-paybutton-ajax.php @@ -266,7 +266,7 @@ public function save_address() { } // blacklist End - PayButton_State::set_address( $address ); + PayButton_State::set_address( $address ); wp_send_json_success(); } From 13b78ee7ee10941599f7daee929ec021ba6e6528 Mon Sep 17 00:00:00 2001 From: xecdev Date: Wed, 26 Nov 2025 08:16:58 +0430 Subject: [PATCH 6/6] comment debug logs --- includes/class-paybutton-ajax.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/includes/class-paybutton-ajax.php b/includes/class-paybutton-ajax.php index 95c8a51..3500a18 100644 --- a/includes/class-paybutton-ajax.php +++ b/includes/class-paybutton-ajax.php @@ -149,7 +149,7 @@ public function payment_trigger() { "SELECT id FROM {$login_table} WHERE wallet_address = %s AND tx_hash = %s LIMIT 1", $user_address, $tx_hash ) ); - error_log('[paybutton] login-branch addr=' . $user_address . ' tx=' . $tx_hash . ' ts=' . $tx_timestamp); + //error_log('[paybutton] login-branch addr=' . $user_address . ' tx=' . $tx_hash . ' ts=' . $tx_timestamp); if ( ! $exists ) { $wpdb->insert( $login_table, @@ -164,11 +164,11 @@ public function payment_trigger() { ); } - if ($wpdb->last_error) { - error_log('[paybutton] insert error: ' . $wpdb->last_error); - } else { - error_log('[paybutton] insert ok id=' . $wpdb->insert_id); - } + // if ($wpdb->last_error) { + // error_log('[paybutton] insert error: ' . $wpdb->last_error); + // } else { + // error_log('[paybutton] insert ok id=' . $wpdb->insert_id); + // } wp_send_json_success(['message' => 'Login tx recorded']); return;