diff --git a/assets/css/paybutton-admin.css b/assets/css/paybutton-admin.css index f603a1e..d0bb8ac 100644 --- a/assets/css/paybutton-admin.css +++ b/assets/css/paybutton-admin.css @@ -82,6 +82,12 @@ border: none; } +/* Disabled state for buttons*/ +.paybutton-disabled { + opacity: 0.55; + cursor: not-allowed; +} + /* ------------------------------ Button Generator Page Styles ------------------------------ */ diff --git a/assets/css/sticky-header.css b/assets/css/sticky-header.css index c010792..1b958c8 100644 --- a/assets/css/sticky-header.css +++ b/assets/css/sticky-header.css @@ -117,7 +117,7 @@ } /* Prevent header from overlapping content */ -body { +body.pb-has-sticky-header { padding-top: 80px !important; } diff --git a/assets/icons/eCash.png b/assets/icons/eCash.png new file mode 100644 index 0000000..4fdab54 Binary files /dev/null and b/assets/icons/eCash.png differ diff --git a/assets/js/addressValidator.bundle.js b/assets/js/addressValidator.bundle.js index 1a7d060..6113fa1 100644 --- a/assets/js/addressValidator.bundle.js +++ b/assets/js/addressValidator.bundle.js @@ -752,45 +752,93 @@ window.cashaddrExports = cashaddrExports; document.addEventListener('DOMContentLoaded', () => { - // Find the wallet address input field by its ID. - const addressInput = document.getElementById('paybutton_admin_wallet_address'); - if (!addressInput) return; - - // Find or create a span for validation feedback. - let resultSpan = document.getElementById('adminAddressValidationResult'); - if (!resultSpan) { - resultSpan = document.createElement('span'); - resultSpan.id = 'adminAddressValidationResult'; - addressInput.parentNode.appendChild(resultSpan); - } - - // Select the "Save Changes" button by its name attribute. - const saveButton = document.querySelector('button[name="paybutton_paywall_save_settings"]'); - - // Listen to input events to auto-validate as the user types. - addressInput.addEventListener('input', () => { - const address = addressInput.value.trim(); - if (address === "") { - resultSpan.textContent = ''; - resultSpan.style.color = ''; - if (saveButton) saveButton.disabled = true; - return; + + // Define the targets: Input ID, Button Name (to disable/enable), and Context + const targets = [ + { + input: 'paybutton_admin_wallet_address', + btnName: 'paybutton_paywall_save_settings', + context: 'paywall' + }, + { + input: 'pbGenTo', + btnName: null, // Generator doesn't have a save button to block + context: 'generator' + }, + { + input: 'woocommerce_paybutton_address', + btnName: 'save', // Standard WooCommerce save button name + context: 'woo' } + ]; + + targets.forEach(target => { + const addressInput = document.getElementById(target.input); + if (!addressInput) return; + + // Create a unique span ID for this input + const resultSpanId = target.input + '_validation_result'; - const valid = cashaddrExports.isValidCashAddress(address); - if (valid) { - resultSpan.textContent = '✅ Valid address'; - resultSpan.style.color = 'green'; - if (saveButton) saveButton.disabled = false; - } else { - resultSpan.textContent = '❌ Invalid address'; - resultSpan.style.color = 'red'; - if (saveButton) saveButton.disabled = true; + // Find or create a span for validation feedback. + let resultSpan = document.getElementById(resultSpanId); + + if (!resultSpan) { + resultSpan = document.createElement('span'); + resultSpan.id = resultSpanId; + + // STYLING UPDATES + resultSpan.style.display = 'block'; + resultSpan.style.fontWeight = 'bold'; + // Add spacing below the text so it doesn't touch the input + resultSpan.style.marginBottom = '5px'; + + // PLACEMENT FIX (ABOVE INPUT BOX) + // This inserts the resultSpan immediately BEFORE the addressInput element. + // On the Generator page, this will place it between the Label and the Input. + addressInput.parentNode.insertBefore(resultSpan, addressInput); + } + + // Select the "Save Changes" button if applicable + let saveButton = null; + if (target.btnName) { + saveButton = document.querySelector(`button[name="${target.btnName}"]`); } + + const validateAddress = () => { + const address = addressInput.value.trim(); + + // Reset if empty + if (address === "") { + resultSpan.textContent = ''; + // When empty, we hide the margin so it doesn't create a blank gap above the input + resultSpan.style.marginBottom = '0px'; + resultSpan.style.color = ''; + + if (saveButton) saveButton.disabled = true; + return; + } + + // Restore margin when text is visible + resultSpan.style.marginBottom = '3px'; + + const valid = cashaddrExports.isValidCashAddress(address); + + if (valid) { + resultSpan.textContent = '✅ Valid address'; + resultSpan.style.color = 'green'; + if (saveButton) saveButton.disabled = false; + } else { + resultSpan.textContent = '❌ Invalid address'; + resultSpan.style.color = 'red'; + if (saveButton) saveButton.disabled = true; + } + }; + + // Listen to input events + addressInput.addEventListener('input', validateAddress); + + // Run immediately on load + validateAddress(); }); - - // Run validation immediately on page load in case the field already has a value. - addressInput.dispatchEvent(new Event('input')); }); - })(); \ No newline at end of file diff --git a/assets/js/paybutton-blocks.js b/assets/js/paybutton-blocks.js new file mode 100644 index 0000000..dd5b06d --- /dev/null +++ b/assets/js/paybutton-blocks.js @@ -0,0 +1,76 @@ +/** + * WooCommerce PayButton Blocks Integration JS +*/ +(function( wc, wp ) { + const { registerPaymentMethod } = wc.wcBlocksRegistry; + const { getSetting } = wc.wcSettings; + const { decodeEntities } = wp.htmlEntities; + const { createElement } = wp.element; + + const settings = getSetting( 'paybutton_data', {} ); + + const labelText = decodeEntities( settings.title || 'PayButton' ); + + // Create a Custom Label Component (Dual Icons) + const LabelIconOnly = () => { + return createElement( + 'span', + { + style: { + display: 'flex', + alignItems: 'center', + width: '100%', + } + }, + // 1. The PayButton Image + settings.icon ? createElement( 'img', { + src: settings.icon, + alt: labelText, + style: { + maxHeight: '30px', + objectFit: 'contain' + } + } ) : null, + + // 2. The Pipeline Separator (Only shows if BOTH icons exist) + (settings.icon && settings.icon2) ? createElement( 'span', { + style: { + margin: '0 10px', // Spacing around the pipe + color: '#ccc', // Light gray color + fontSize: '24px', // Size of the pipe + lineHeight: '1', + fontWeight: '300' + } + }, '|' ) : null, + + // 3. The eCash Image + settings.icon2 ? createElement( 'img', { + src: settings.icon2, + alt: 'eCash', + style: { + maxHeight: '24px', + objectFit: 'contain' + } + } ) : null, + + // 4. Fallback: If no icons are found, show text + (!settings.icon && !settings.icon2) ? createElement( 'span', null, labelText ) : null + ); + }; + + const Content = () => { + return createElement( 'div', null, decodeEntities( settings.description || '' ) ); + }; + + registerPaymentMethod( { + name: 'paybutton', + label: createElement( LabelIconOnly ), + content: createElement( Content ), + edit: createElement( Content ), + canMakePayment: () => true, + ariaLabel: labelText, + supports: { + features: settings.supports, + }, + } ); +})( window.wc, window.wp ); \ No newline at end of file diff --git a/assets/js/paybutton-generator.js b/assets/js/paybutton-generator.js index 3e78009..67c8070 100644 --- a/assets/js/paybutton-generator.js +++ b/assets/js/paybutton-generator.js @@ -3,44 +3,6 @@ (function($) { "use strict"; - /* ========================================================================== - ADMIN WALLET ADDRESS VALIDATION - ========================================================================== - */ - if ($('#pbGenTo').length) { - - const $toField = $('#pbGenTo'); - let $validationMsg; - - if (!$('#pbGenToValidationResult').length) { - $toField.after('
'); - } - $validationMsg = $('#pbGenToValidationResult'); - - $toField.on('input', function() { - const address = $toField.val().trim(); - - if (!address) { - $validationMsg.text('').css('color', ''); - return; - } - - const valid = window.cashaddrExports && window.cashaddrExports.isValidCashAddress(address); - if (typeof window.cashaddrExports === 'undefined') { - console.error('[PayButton] addressValidator is missing or not loaded!'); - } - - if (valid) { - $validationMsg.text('✅ Valid address').css('color', 'green'); - } else { - $validationMsg.text('❌ Invalid address').css('color', 'red'); - } - }); - - // Trigger input event on page load to validate pre-set value (from Paywall Settings). - $toField.trigger('input'); - } - /* ========================================================================== BUTTON GENERATOR LOGIC ========================================================================== diff --git a/assets/js/paybutton-woo.js b/assets/js/paybutton-woo.js new file mode 100644 index 0000000..d8087a2 --- /dev/null +++ b/assets/js/paybutton-woo.js @@ -0,0 +1,57 @@ +/** + * WooCommerce PayButton Integration JS +*/ +jQuery(document).ready(function($) { + $('.paybutton-woo-container').each(function() { + var $container = $(this); + var configData = $container.data('config'); + + if (typeof configData === 'string') { + try { configData = JSON.parse(configData); } + catch (e) { return; } + } + + let paymentInitiated = false; + + function showOverlay(msg) { + const el = document.getElementById('paybutton_overlay'); + if (el) { + document.getElementById('paybutton_overlay_text').innerText = msg; + el.style.display = 'block'; + } + } + + function pollOrderStatus() { + setInterval(function() { + $.ajax({ + url: PaywallAjax.ajaxUrl, + method: 'POST', + data: { + action: 'paybutton_check_order_status', + security: PaywallAjax.nonce, + order_id: configData.opReturn + }, + success: function(response) { + if (response.success) { + // Success! Reload to hide button and show receipt + location.reload(); + } + } + }); + }, 3000); + } + + PayButton.render($container[0], { + ...configData, + onSuccess: function(tx) { + paymentInitiated = true; + pollOrderStatus(); + }, + onClose: function() { + if (paymentInitiated) { + showOverlay("Verifying Payment..."); + } + } + }); + }); +}); \ No newline at end of file diff --git a/includes/class-paybutton-activator.php b/includes/class-paybutton-activator.php index 06ec1c0..f2ef3e2 100644 --- a/includes/class-paybutton-activator.php +++ b/includes/class-paybutton-activator.php @@ -19,17 +19,11 @@ public static function activate() { self::create_profile_page(); // Set a flag to redirect the admin to the Paywall Settings page after activation update_option('paybutton_activation_redirect', true); - self::migrate_old_option(); + //self::migrate_old_option(); } private static function migrate_old_option() { - // --- 1. unlocked‑indicator colours --- - $txt_old = get_option( 'paybutton_unlocked_indicator_text_color', '' ); - $txt_new = get_option( 'paybutton_unlocked_indicator_color', '' ); - if ( ! empty( $txt_old ) && empty( $txt_new ) ) { - update_option( 'paybutton_unlocked_indicator_color', $txt_old ); - delete_option( 'paybutton_unlocked_indicator_text_color' ); - } + // Empty function for future use } /** @@ -55,7 +49,7 @@ public static function create_tables() { PRIMARY KEY (id), KEY pb_paywall_user_wallet_address_idx (pb_paywall_user_wallet_address), KEY post_id_idx (post_id), - KEY tx_hash_idx (tx_hash), + UNIQUE KEY tx_hash_idx (tx_hash), KEY unlock_token_idx (unlock_token) ) $charset_collate;"; @@ -75,7 +69,7 @@ public static function create_tables() { used TINYINT(1) NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), - KEY tx_hash_idx (tx_hash), + UNIQUE KEY tx_hash_idx (tx_hash), KEY wallet_addr_idx (wallet_address(190)), KEY used_idx (used), KEY login_token_idx (login_token) diff --git a/includes/class-paybutton-admin.php b/includes/class-paybutton-admin.php index 7f5b5b3..6a62628 100644 --- a/includes/class-paybutton-admin.php +++ b/includes/class-paybutton-admin.php @@ -42,7 +42,7 @@ public function add_admin_menus() { add_submenu_page( 'paybutton', 'Button Generator', - 'Button Generator ', + 'Button Generator', 'manage_options', 'paybutton-generator', array( $this, 'button_generator_page' ) @@ -85,7 +85,7 @@ public function handle_save_settings() { ) { $this->save_settings(); wp_cache_delete( 'paybutton_admin_wallet_address', 'options' ); - wp_redirect( admin_url( 'admin.php?page=paybutton-paywall&settings-updated=true' ) ); + wp_safe_redirect( admin_url( 'admin.php?page=paybutton-paywall&settings-updated=true' ) ); exit; } } @@ -99,7 +99,7 @@ public function handle_save_settings() { * color selection functionality. */ public function enqueue_admin_scripts( $hook_suffix ) { - // Enqueue the paybutton-admin.css on every admin page + wp_enqueue_style( 'paybutton-admin', PAYBUTTON_PLUGIN_URL . 'assets/css/paybutton-admin.css', @@ -116,6 +116,10 @@ public function enqueue_admin_scripts( $hook_suffix ) { true ); + // --- SAFE SCREEN DETECTION --- + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + + // 1) Paywall Settings page if ( $hook_suffix === 'paybutton_page_paybutton-paywall' ) { wp_enqueue_style( 'wp-color-picker' ); wp_enqueue_script( 'wp-color-picker' ); @@ -130,10 +134,8 @@ public function enqueue_admin_scripts( $hook_suffix ) { ); } - // Only load the generator JS on the PayButton Generator page + // 2) Button Generator page if ( $hook_suffix === 'paybutton_page_paybutton-generator' ) { - - // Enqueue the bundled address validator script wp_enqueue_script( 'address-validator', PAYBUTTON_PLUGIN_URL . 'assets/js/addressValidator.bundle.js', @@ -158,6 +160,20 @@ public function enqueue_admin_scripts( $hook_suffix ) { true ); } + + // 3) WooCommerce → Settings → Payments (wallet address field) + if ( + $hook_suffix === 'woocommerce_page_wc-settings' + || ( $screen && $screen->base === 'woocommerce_page_wc-settings' ) + ) { + wp_enqueue_script( + 'address-validator', + PAYBUTTON_PLUGIN_URL . 'assets/js/addressValidator.bundle.js', + array(), + '2.0.0', + true + ); + } } /** @@ -183,7 +199,9 @@ private function load_admin_template( $template_name, $args = array() ) { public function dashboard_page() { $args = array( 'generate_button_url' => esc_url( admin_url( 'admin.php?page=paybutton-generator' ) ), - 'paywall_settings_url' => esc_url( admin_url( 'admin.php?page=paybutton-paywall' ) ) + 'paywall_settings_url' => esc_url( admin_url( 'admin.php?page=paybutton-paywall' ) ), + 'woocommerce_payments_url'=> esc_url( admin_url( 'admin.php?page=wc-settings&tab=checkout' ) ), + 'woocommerce_installed' => class_exists( 'WooCommerce' ) ); $this->load_admin_template( 'dashboard', $args ); } @@ -331,6 +349,11 @@ private function save_settings() { ? sanitize_hex_color( wp_unslash( $_POST['paybutton_frontend_unlock_color'] ) ) : '#0074C2'; update_option( 'paybutton_frontend_unlock_color', $frontend_unlock_color ); + + update_option( + 'paybutton_hide_sticky_header', + isset( $_POST['paybutton_hide_sticky_header'] ) ? '1' : '0' + ); } /** diff --git a/includes/class-paybutton-ajax.php b/includes/class-paybutton-ajax.php index 8919851..374a1b1 100644 --- a/includes/class-paybutton-ajax.php +++ b/includes/class-paybutton-ajax.php @@ -54,6 +54,10 @@ public function __construct() { // AJAX endpoint to get sticky header HTML for auto-login after content unlock add_action( 'wp_ajax_paybutton_get_sticky_header', array( $this, 'get_sticky_header' ) ); add_action( 'wp_ajax_nopriv_paybutton_get_sticky_header', array( $this, 'get_sticky_header' ) ); + + // WooCommerce Order Status Polling + add_action( 'wp_ajax_paybutton_check_order_status', array( $this, 'check_order_status' ) ); + add_action( 'wp_ajax_nopriv_paybutton_check_order_status', array( $this, 'check_order_status' ) ); } /** * Payment Trigger Handler with Cryptographic Verification @@ -103,6 +107,7 @@ public function payment_trigger() { $ts_raw = $json['tx_timestamp'] ?? 0; $user_addr_raw = $json['user_address'][0]['address'] ?? ($json['user_address'][0] ?? ''); $currency_raw = $json['currency'] ?? ''; + $fiatValue = $json['value'] ?? 0; unset( $json ); // discard the rest immediately @@ -119,17 +124,42 @@ public function payment_trigger() { } // Verify the signature - $verification_result = $this->verify_signature($payload, $signature, $public_key); + $verification_result = PayButton_Transactions::verify_signature( + $payload, + $signature, + $public_key + ); + if (!$verification_result) { wp_send_json_error(['message' => 'Signature verification failed.']); return; } // error_log('[paybutton] signature ok'); + // Ensure OP_RETURN (rawMessage) is cryptographically bound to the payload + $op_return = (string) $post_id_raw; + $op_return = trim($op_return); + + if ( $op_return === '' || strpos( $payload, $op_return ) === false ) { + wp_send_json_error( + [ 'message' => 'Payload does not match OP_RETURN.' ], + 400 + ); + return; + } + + if ( $tx_hash_raw && strpos( $payload, $tx_hash_raw ) === false ) { + wp_send_json_error( + [ 'message' => 'Payload does not match tx_hash.' ], + 400 + ); + return; + } + //Sanitize data $post_id = intval( $post_id_raw ); $tx_hash = sanitize_text_field( $tx_hash_raw ); - $tx_amount = sanitize_text_field( $tx_amount_raw ); + $tx_amount = (float) $tx_amount_raw; $tx_timestamp = intval( $ts_raw ); $user_address = sanitize_text_field( $user_addr_raw ); $currency = sanitize_text_field( $currency_raw ); @@ -147,38 +177,55 @@ public function payment_trigger() { 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); - // } + PayButton_Transactions::record_login_tx_if_new( + $user_address, + $tx_hash, + (float) $tx_amount, + (int) $tx_timestamp + ); wp_send_json_success(['message' => 'Login tx recorded']); return; } + + // --- BRANCH 2: WOOCOMMERCE PAYMENTS --- + if ( class_exists( 'WooCommerce' ) ) { + $order = wc_get_order( $post_id ); + + if ( $order && $order instanceof WC_Order ) { + + // 1. Check if already paid + if ( $order->is_paid() ) { + wp_send_json_success( array( 'message' => 'Order already paid' ) ); + return; + } + + // 2. Validate Amount using Fiat Value (USD) from Webhook + // We compare the webhook's USD value ($fiatValue) against the Order Total. + + $expected_fiat = (float) $order->get_total(); + // Allow small epsilon for floating point math + if ( $fiatValue < ( $expected_fiat - 0.05 ) ) { + // Add note about underpayment but don't mark complete yet + $order->add_order_note( sprintf( 'Underpayment detected. Expected $%s but received $%s worth of crypto. Tx: %s', $expected_fiat, $fiatValue, $tx_hash ) ); + wp_send_json_error( array( 'message' => 'Insufficient fiat value.' ), 400 ); + return; + } + + // 3. Mark as Paid + $order->payment_complete( $tx_hash ); + + // 4. Add informative note + $note = sprintf( 'PayButton Payment Received via Webhook. Value: $%s. Tx Hash: %s', $fiatValue, $tx_hash ); + $order->add_order_note( $note ); + $order->update_meta_data( '_paybutton_tx_hash', $tx_hash ); + $order->update_meta_data( '_paybutton_fiat_value', $fiatValue ); + $order->save(); + + wp_send_json_success( array( 'message' => 'WooCommerce Order Updated' ) ); + return; // Stop processing + } + } // Convert timestamp to MySQL datetime $mysql_timestamp = $tx_timestamp ? gmdate('Y-m-d H:i:s', $tx_timestamp) : '0000-00-00 00:00:00'; @@ -204,7 +251,7 @@ public function payment_trigger() { } // Get expected price and unit by parsing shortcode in the post content - $required = $this->paybutton_get_paywall_requirements( $post_id ); + $required = PayButton_Transactions::get_paywall_requirements( $post_id ); if ( $required === null ) { wp_send_json_error( array( 'message' => 'Post not configured for paywall.' ), 400 ); return; @@ -212,28 +259,29 @@ public function payment_trigger() { $expected_price = floatval( $required['price'] ); $expected_unit = strtoupper( $required['unit'] ); - - - // Numeric amount check - $paid_amount = floatval( $tx_amount ); - $epsilon = 0.05; // tolerance for rounding differences - if ( $paid_amount + $epsilon < $expected_price ) { - wp_send_json_error( array( 'message' => 'Underpaid transaction ignored.' ), 400 ); - return; - } - - if ( $incoming_unit !== $expected_unit ) { - wp_send_json_error( array( 'message' => 'Currency/unit mismatch.' ), 400 ); + + if ( + ! PayButton_Transactions::validate_price_and_unit( + (float) $tx_amount, + $incoming_unit, + $expected_price, + $expected_unit + ) + ) { + wp_send_json_error( + array( 'message' => 'Invalid payment amount or currency.' ), + 400 + ); return; } // Passed validation -> store unlock in DB $is_logged_in = 0; - $this->store_unlock_in_db( + PayButton_Transactions::insert_unlock_if_new( sanitize_text_field( $user_address ), $post_id, sanitize_text_field( $tx_hash ), - floatval( $tx_amount ), + (float) $tx_amount, $mysql_timestamp, $is_logged_in ); @@ -241,92 +289,6 @@ public function payment_trigger() { wp_send_json_success(); } - // Verify the signature using the public key - private function verify_signature($payload, $signature, $public_key_hex) { - // Convert hex signature to binary - $binary_signature = hex2bin($signature); - if (!$binary_signature) { - return false; - } - - // Convert hex public key to binary - $binary_public_key = hex2bin($public_key_hex); - if (!$binary_public_key) { - return false; - } - - // If the public key is in DER format (44 bytes), extract the raw 32-byte key. - if (strlen($binary_public_key) === 44) { - $raw_public_key = substr($binary_public_key, 12); - } else { - $raw_public_key = $binary_public_key; - } - - // Ensure payload is in exact binary format - $binary_payload = mb_convert_encoding($payload, 'ISO-8859-1', 'UTF-8'); - - // Verify signature using Sodium (Ed25519) - $verification = sodium_crypto_sign_verify_detached($binary_signature, $binary_payload, $raw_public_key); - - if ($verification) { - return true; - } else { - return false; - } - } - - /** - * Get expected paywall price and unit for a post/page by parsing its - * first [paywalled_content] shortcode. - * - * @param int $post_id - * @return array|null Array( 'price' => float, 'unit' => string ) or null if not paywalled. - */ - private function paybutton_get_paywall_requirements( $post_id ) { - $post_id = absint( $post_id ); - if ( ! $post_id ) { - return null; - } - - $post = get_post( $post_id ); - if ( ! $post || ! isset( $post->post_content ) ) { - return null; - } - - $content = $post->post_content; - - // Capture the first [paywalled_content ...] opening tag attributes - if ( preg_match( '/\[paywalled_content([^\]]*)\]/i', $content, $matches ) ) { - $atts_raw = isset( $matches[1] ) ? $matches[1] : ''; - $atts = shortcode_parse_atts( $atts_raw ); - - $price = null; - $unit = ''; - - if ( isset( $atts['price'] ) && $atts['price'] !== '' ) { - $price = floatval( trim( $atts['price'] ) ); - } - - if ( isset( $atts['unit'] ) && $atts['unit'] !== '' ) { - $unit = strtoupper( sanitize_text_field( trim( $atts['unit'] ) ) ); - } - - // Fallbacks to plugin options - if ( $price === null || $price === 0.0 ) { - $price = floatval( get_option( 'paybutton_paywall_default_price', 5.5 ) ); - } - if ( $unit === '' ) { - $unit = strtoupper( sanitize_text_field( get_option( 'paybutton_paywall_unit', 'XEC' ) ) ); - } - - return array( - 'price' => $price, - 'unit' => $unit, - ); - } - - return null; - } /** * The following function sets the user's wallet address in a cookie via AJAX after * a successful login transaction. @@ -392,11 +354,11 @@ public function mark_payment_successful() { $post_id = isset( $_POST['post_id'] ) ? intval( $_POST['post_id'] ) : 0; $tx_hash = isset( $_POST['tx_hash'] ) ? sanitize_text_field( $_POST['tx_hash'] ) : ''; - $tx_amount = isset( $_POST['tx_amount'] ) ? sanitize_text_field( $_POST['tx_amount'] ) : ''; + $tx_amount = isset( $_POST['tx_amount'] ) ? (float) $_POST['tx_amount'] : 0.0; $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'] ) : ''; + $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 ); @@ -561,38 +523,6 @@ private function extract_shortcode_inner_content( $post_content ) { return $inner; } - /** - * Store the unlock information in the database. - */ - private function store_unlock_in_db( $address, $post_id, $tx_hash, $tx_amount, $tx_dt, $is_logged_in ) { - global $wpdb; - $table_name = $wpdb->prefix . 'paybutton_paywall_unlocked'; - - // Check if the transaction already exists using tx hash - $exists = $wpdb->get_var($wpdb->prepare( - "SELECT id FROM $table_name WHERE tx_hash = %s LIMIT 1", - $tx_hash - )); - - if ($exists) { - return; // Transaction already recorded, so we don't insert again. - } - - // Insert the transaction if it's not already recorded - $wpdb->insert( - $table_name, - array( - 'pb_paywall_user_wallet_address' => $address, - 'post_id' => $post_id, - 'tx_hash' => $tx_hash, - 'tx_amount' => $tx_amount, - 'tx_timestamp' => $tx_dt, - 'is_logged_in' => $is_logged_in, - ), - 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 @@ -610,41 +540,21 @@ public function ajax_validate_login_tx() { } 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') + $token = PayButton_Transactions::consume_row_and_attach_token( + $wpdb->prefix . 'paybutton_logins', + [ + 'wallet_address' => $wallet_address, + 'tx_hash' => $tx_hash, + ], + 'login_token' ); - wp_send_json_success(array( - 'login_token' => $token, - )); + if (!$token) { + wp_send_json_error('Login validation failed'); + } + + wp_send_json_success(['login_token' => $token]); } /** @@ -665,45 +575,22 @@ public function ajax_validate_unlock_tx() { } 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' ) + $token = PayButton_Transactions::consume_row_and_attach_token( + $wpdb->prefix . 'paybutton_paywall_unlocked', + [ + 'pb_paywall_user_wallet_address' => $wallet_address, + 'tx_hash' => $tx_hash, + 'post_id' => $post_id, + ], + 'unlock_token' ); - wp_send_json_success(array( - 'unlock_token' => $token, - )); + if (!$token) { + wp_send_json_error('Unlock validation failed'); + } + + wp_send_json_success(['unlock_token' => $token]); } /** @@ -712,6 +599,10 @@ public function ajax_validate_unlock_tx() { public function get_sticky_header() { check_ajax_referer( 'paybutton_paywall_nonce', 'security' ); + if ( get_option( 'paybutton_hide_sticky_header', '0' ) === '1' ) { + wp_send_json_success( array( 'html' => '' ) ); + } + $template = PAYBUTTON_PLUGIN_DIR . 'templates/public/sticky-header.php'; if ( ! file_exists( $template ) ) { wp_send_json_error( array( 'message' => 'Sticky header template not found.' ), 500 ); @@ -728,4 +619,27 @@ public function get_sticky_header() { 'html' => $html, ) ); } + + /** + * Polls to see if a WooCommerce order has been paid. + */ + public function check_order_status() { + check_ajax_referer( 'paybutton_paywall_nonce', 'security' ); + + if ( ! class_exists( 'WooCommerce' ) ) wp_send_json_error(); + + $order_id = isset( $_POST['order_id'] ) ? intval( $_POST['order_id'] ) : 0; + $order = wc_get_order( $order_id ); + + if ( $order && $order->is_paid() ) { + wp_send_json_success(); + } + + // Also check if status is processing (for manual verification workflows) + if ( $order && ( $order->has_status( 'processing' ) || $order->has_status( 'completed' ) ) ) { + wp_send_json_success(); + } + + wp_send_json_error(); + } } \ No newline at end of file diff --git a/includes/class-paybutton-public.php b/includes/class-paybutton-public.php index 89433dc..b141b50 100644 --- a/includes/class-paybutton-public.php +++ b/includes/class-paybutton-public.php @@ -33,6 +33,8 @@ public function __construct() { add_filter( 'preprocess_comment', array( $this, 'block_comment_if_locked' ) ); // Hard-block comment creation via REST API as well when the paywalled content is locked add_filter( 'rest_pre_insert_comment', array( $this, 'block_comment_if_locked_rest' ), 10, 2 ); + add_action( 'wp_footer', array( $this, 'output_paybutton_overlay' ), 5 ); + add_filter( 'body_class', array( $this, 'filter_body_classes' ) ); } /** @@ -163,6 +165,9 @@ private function load_public_template( $template_name, $args = array() ) { * Output the sticky header HTML. */ public function output_sticky_header() { + if ( get_option( 'paybutton_hide_sticky_header', '0' ) === '1' ) { + return; + } $paybutton_user_wallet_address = sanitize_text_field( PayButton_State::get_address() ); $this->load_public_template( 'sticky-header', array( 'paybutton_user_wallet_address' => $paybutton_user_wallet_address @@ -376,4 +381,19 @@ public function block_comment_if_locked_rest( $prepared, $request ) { } return $prepared; } + /** + * Output the PayButton overlay HTML. + */ + public function output_paybutton_overlay() { + $this->load_public_template( 'paybutton-overlay' ); + } + /** + * Filter to add custom body classes. + */ + public function filter_body_classes( $classes ) { + if ( get_option( 'paybutton_hide_sticky_header', '0' ) !== '1' ) { + $classes[] = 'pb-has-sticky-header'; + } + return $classes; + } } \ No newline at end of file diff --git a/includes/class-paybutton-transactions.php b/includes/class-paybutton-transactions.php new file mode 100644 index 0000000..bef9ef2 --- /dev/null +++ b/includes/class-paybutton-transactions.php @@ -0,0 +1,285 @@ +prefix . 'paybutton_logins', + $wpdb->prefix . 'paybutton_paywall_unlocked', + ]; + + if ( ! in_array( $table, $allowed_tables, true ) ) { + return null; + } + + // Explicit column whitelist + $allowed_columns = [ + // login table + 'wallet_address', + + // unlock table + 'pb_paywall_user_wallet_address', + 'post_id', + + // shared + 'tx_hash', + 'used', + ]; + + $conditions = []; + $values = []; + + foreach ( $where as $column => $value ) { + + if ( ! in_array( $column, $allowed_columns, true ) ) { + continue; + } + + $conditions[] = "`{$column}` = %s"; + $values[] = $value; + } + + if ( empty( $conditions ) ) { + return null; + } + + // We construct the SQL dynamically here because the WHERE clause varies. + $sql = " + SELECT id + FROM `{$table}` + WHERE " . implode( ' AND ', $conditions ) . " + AND used = 0 + ORDER BY id DESC + LIMIT 1 + "; + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is constructed dynamically with whitelisted columns and placeholders. + $row = $wpdb->get_row( $wpdb->prepare( $sql, ...$values ) ); + + if (!$row) { + return null; + } + + $token = self::generate_secure_token(); + + $wpdb->update( + $table, + [ + 'used' => 1, + $token_column => $token, + ], + [ 'id' => (int) $row->id ], + [ '%d', '%s' ], + [ '%d' ] + ); + + return $token; + } + + /* ============================================================ + * Price & currency validation + * ============================================================ + */ + + public static function validate_price_and_unit( + float $paid_amount, + string $paid_unit, + float $expected_price, + string $expected_unit + ): bool { + + $epsilon = 0.05; + + if ($paid_amount + $epsilon < $expected_price) { + return false; + } + + if (strtoupper($paid_unit) !== strtoupper($expected_unit)) { + return false; + } + + return true; + } + + /* ============================================================ + * Store the unlock information in the database. + * ============================================================ + */ + public static function insert_unlock_if_new( + string $address, + int $post_id, + string $tx_hash, + float $tx_amount, + string $tx_dt, + int $is_logged_in + ): void { + + global $wpdb; + $table_name = $wpdb->prefix . 'paybutton_paywall_unlocked'; + + // Insert the transaction if it's not already recorded + $wpdb->query( + $wpdb->prepare( + "INSERT IGNORE INTO {$table_name} + (pb_paywall_user_wallet_address, post_id, tx_hash, tx_amount, tx_timestamp, is_logged_in) + VALUES (%s, %d, %s, %f, %s, %d)", + $address, + $post_id, + $tx_hash, + $tx_amount, + $tx_dt, + $is_logged_in + ) + ); + } + + /* ============================================================ + * Store the login tx information in the database. + * ============================================================ + */ + + public static function record_login_tx_if_new( + string $wallet_address, + string $tx_hash, + float $tx_amount, + int $tx_timestamp + ): void { + + global $wpdb; + $table = $wpdb->prefix . 'paybutton_logins'; + + $wpdb->query( + $wpdb->prepare( + "INSERT IGNORE INTO {$table} + (wallet_address, tx_hash, tx_amount, tx_timestamp, used) + VALUES (%s, %s, %f, %d, 0)", + $wallet_address, + $tx_hash, + $tx_amount, + $tx_timestamp + ) + ); + } + + + /** + * Get expected paywall price and unit for a post/page by parsing its + * first [paywalled_content] shortcode. + * + * @param int $post_id + * @return array|null Array( 'price' => float, 'unit' => string ) or null if not paywalled. + */ + public static function get_paywall_requirements( int $post_id ): ?array { + + $post_id = absint( $post_id ); + if ( ! $post_id ) { + return null; + } + + $post = get_post( $post_id ); + if ( ! $post || ! isset( $post->post_content ) ) { + return null; + } + + $content = $post->post_content; + + // Capture the first [paywalled_content ...] opening tag attributes + if ( preg_match( '/\[paywalled_content([^\]]*)\]/i', $content, $matches ) ) { + $atts_raw = isset( $matches[1] ) ? $matches[1] : ''; + $atts = shortcode_parse_atts( $atts_raw ); + + $price = null; + $unit = ''; + + if ( isset( $atts['price'] ) && $atts['price'] !== '' ) { + $price = floatval( trim( $atts['price'] ) ); + } + + if ( isset( $atts['unit'] ) && $atts['unit'] !== '' ) { + $unit = strtoupper( sanitize_text_field( trim( $atts['unit'] ) ) ); + } + + // Fallbacks to plugin options + if ( $price === null || $price === 0.0 ) { + $price = floatval( get_option( 'paybutton_paywall_default_price', 5.5 ) ); + } + if ( $unit === '' ) { + $unit = strtoupper( sanitize_text_field( get_option( 'paybutton_paywall_unit', 'XEC' ) ) ); + } + + return array( + 'price' => $price, + 'unit' => $unit, + ); + } + + return null; + } +} \ No newline at end of file diff --git a/includes/woocommerce/class-paybutton-blocks-support.php b/includes/woocommerce/class-paybutton-blocks-support.php new file mode 100644 index 0000000..c7682c7 --- /dev/null +++ b/includes/woocommerce/class-paybutton-blocks-support.php @@ -0,0 +1,64 @@ +payment_gateways->payment_gateways(); + + if ( isset( $gateways[ $this->name ] ) ) { + return $gateways[ $this->name ]->is_available(); + } + + return false; + } + + /** + * Register the payment method script handles. + */ + public function get_payment_method_script_handles() { + wp_register_script( + 'wc-paybutton-blocks', + PAYBUTTON_PLUGIN_URL . 'assets/js/paybutton-blocks.js', + array( 'wc-blocks-registry', 'wc-settings', 'wp-element', 'wp-html-entities' ), + '1.0.0', + true + ); + + return array( 'wc-paybutton-blocks' ); + } + + /** + * Get payment method data for the blocks checkout. + */ + public function get_payment_method_data() { + $gateways = WC()->payment_gateways->payment_gateways(); + $gateway = isset( $gateways[ $this->name ] ) ? $gateways[ $this->name ] : null; + + return array( + 'title' => $gateway ? $gateway->get_title() : 'PayButton', + 'description' => $gateway ? $gateway->get_description() : '', + 'icon' => PAYBUTTON_PLUGIN_URL . 'assets/paybutton-logo.png', + // NEW: Secondary icon (eCash) + 'icon2' => PAYBUTTON_PLUGIN_URL . 'assets/icons/eCash.png', + 'supports' => array( 'products' ), + ); + } +} \ No newline at end of file diff --git a/includes/woocommerce/class-wc-gateway-paybutton.php b/includes/woocommerce/class-wc-gateway-paybutton.php new file mode 100644 index 0000000..95659a9 --- /dev/null +++ b/includes/woocommerce/class-wc-gateway-paybutton.php @@ -0,0 +1,234 @@ +id = 'paybutton'; + $this->icon = PAYBUTTON_PLUGIN_URL . 'assets/icon-128x128.jpg'; + $this->has_fields = false; + $this->method_title = 'PayButton'; + $this->method_description = 'Accept eCash (XEC) payments directly via PayButton.'; + + // Load settings + $this->init_form_fields(); + $this->init_settings(); + + $this->title = $this->get_option( 'title' ); + $this->description = $this->get_option( 'description' ); + + // Admin Options Save Action + add_action( 'woocommerce_update_options_payment_gateways_' . $this->id, array( $this, 'process_admin_options' ) ); + + // Inject PayButton on the Thank You page + add_action( 'woocommerce_thankyou_' . $this->id, array( $this, 'thankyou_page' ) ); + + // Load Frontend Scripts (Separated Logic) + add_action( 'wp_enqueue_scripts', array( $this, 'payment_scripts' ) ); + + // Render PayButton order metadata in WooCommerce admin + add_action( 'woocommerce_admin_order_data_after_order_details', array( $this, 'render_order_admin_panel' ) ); + } + + /** + * Initialize Gateway Settings Form Fields + */ + public function init_form_fields() { + $this->form_fields = array( + 'enabled' => array( + 'title' => 'Enable/Disable', + 'type' => 'checkbox', + 'label' => 'Enable PayButton Payment', + 'default' => 'no' + ), + 'title' => array( + 'title' => 'Title', + 'type' => 'text', + 'default' => 'PayButton', + 'desc_tip' => true, + ), + 'description' => array( + 'title' => 'Description', + 'type' => 'textarea', + 'default' => 'Pay securely using your eCash wallet.', + ), + 'address' => array( + 'title' => 'Wallet Address', + 'type' => 'text', + 'description' => 'The eCash wallet address where you want to receive payments.', + 'default' => '', + 'desc_tip' => true, + 'placeholder' => 'ecash:qr...', + ), + ); + } + + /** + * Overridden to prevent activation if address is missing. + */ + public function process_admin_options() { + + // Verify WooCommerce settings nonce + if ( + ! isset( $_POST['_wpnonce'] ) || + ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'woocommerce-settings' ) + ) { + return; + } + + $is_enabling = isset( $_POST['woocommerce_paybutton_enabled'] ); + + $posted_address = ''; + if ( isset( $_POST['woocommerce_paybutton_address'] ) ) { + $posted_address = sanitize_text_field( + wp_unslash( $_POST['woocommerce_paybutton_address'] ) + ); + } + + if ( $is_enabling && empty( $posted_address ) ) { + WC_Admin_Settings::add_error( + __( 'PayButton Error: You must enter a wallet address to use this payment method.', 'paybutton' ) + ); + unset( $_POST['woocommerce_paybutton_enabled'] ); + } + + parent::process_admin_options(); + } + + /** + * Process the payment and return the result. + */ + public function process_payment( $order_id ) { + $order = wc_get_order( $order_id ); + + $order->update_status( 'on-hold', __( 'Awaiting PayButton payment.', 'paybutton' ) ); + wc_reduce_stock_levels( $order_id ); + WC()->cart->empty_cart(); + + return array( + 'result' => 'success', + 'redirect' => $this->get_return_url( $order ) + ); + } + + /** + * Separate Script Loading Logic + */ + public function payment_scripts() { + if ( ! is_wc_endpoint_url( 'order-received' ) ) return; + + global $wp; + $order_id = isset( $wp->query_vars['order-received'] ) ? absint( $wp->query_vars['order-received'] ) : 0; + if ( ! $order_id ) return; + + $order = wc_get_order( $order_id ); + if ( ! $order ) return; + + if ( $this->id !== $order->get_payment_method() ) return; + if ( $order->is_paid() ) return; + + wp_enqueue_script( + 'paybutton-woo', + PAYBUTTON_PLUGIN_URL . 'assets/js/paybutton-woo.js', + array( 'jquery', 'paybutton-core' ), + '1.0', + true + ); + + wp_localize_script( 'paybutton-woo', 'PaywallAjax', array( + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'paybutton_paywall_nonce' ) + )); + } + + /** + * Output for the order received page. + */ + public function thankyou_page( $order_id ) { + $order = wc_get_order( $order_id ); + if ( ! $order ) return; + + if ( $order->is_paid() ) { + echo ''; + echo '+ +
+diff --git a/templates/public/paybutton-overlay.php b/templates/public/paybutton-overlay.php new file mode 100644 index 0000000..79c78b2 --- /dev/null +++ b/templates/public/paybutton-overlay.php @@ -0,0 +1,13 @@ + + + +
\ No newline at end of file