Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
47.12% covered (danger)
47.12%
49 / 104
14.29% covered (danger)
14.29%
1 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayPal_Payment_Buttons
47.12% covered (danger)
47.12%
49 / 104
14.29% covered (danger)
14.29%
1 / 7
117.44
0.00% covered (danger)
0.00%
0 / 1
 sanitize_paypal_script_url
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
7
 register_block
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 render_block
54.90% covered (warning)
54.90%
28 / 51
0.00% covered (danger)
0.00%
0 / 1
28.50
 load_editor_styles
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 load_editor_scripts
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 add_style_display
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 register_hooks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * PayPal Payment Buttons block lets users embed a PayPal button to sell products on their site.
4 *
5 * @package automattic/jetpack-paypal-payments
6 */
7
8namespace Automattic\Jetpack\PaypalPayments;
9
10use Automattic\Jetpack\Assets;
11use Automattic\Jetpack\Blocks;
12
13/**
14 * Class PayPal_Payment_Buttons
15 *
16 * @package Automattic\Jetpack\PaypalPayments
17 */
18class PayPal_Payment_Buttons {
19    /**
20     * The block full slugname.
21     *
22     * @var string
23     */
24    public const BLOCK_NAME = 'jetpack/paypal-payment-buttons';
25
26    /**
27     * PayPal partner attribution ID used for tracking.
28     *
29     * @var string
30     */
31    public const PAYPAL_PARTNER_ATTRIBUTION_ID = 'WooNCPS_Ecom_Wordpress';
32
33    /**
34     * Validates and sanitizes a script URL to ensure it's from an allowed PayPal domain.
35     *
36     * @param string $url The URL to validate and sanitize.
37     * @return string|false The sanitized URL, or false if URL is not from an allowed PayPal domain.
38     */
39    public static function sanitize_paypal_script_url( $url ) {
40        if ( empty( $url ) ) {
41            return false;
42        }
43
44        $parsed_url = wp_parse_url( $url );
45        if ( ! $parsed_url || empty( $parsed_url['host'] ) ) {
46            return false;
47        }
48
49        // Normalize the host
50        $host = strtolower( $parsed_url['host'] );
51        $host = rtrim( $host, '.' );
52
53        // Only allow specific PayPal domains
54        $allowed_hosts = array(
55            'www.paypal.com',
56            'paypal.com',
57            'www.sandbox.paypal.com',
58            'sandbox.paypal.com',
59        );
60
61        if ( ! in_array( $host, $allowed_hosts, true ) ) {
62            return false;
63        }
64
65        // Rebuild the URL with HTTPS
66        $sanitized_url = 'https://' . $host;
67
68        if ( isset( $parsed_url['path'] ) ) {
69            $sanitized_url .= $parsed_url['path'];
70        }
71        if ( isset( $parsed_url['query'] ) ) {
72            // If we have escaped ampersands in the query string, we need to unescape them.
73            $sanitized_url .= '?' . str_replace( '&amp;', '&', $parsed_url['query'] );
74        }
75
76        return $sanitized_url;
77    }
78
79    /**
80     * Registers the block for use in Gutenberg
81     * This is done via an action so that we can disable
82     * registration if we need to.
83     */
84    public static function register_block() {
85        Blocks::jetpack_register_block(
86            __DIR__,
87            array(
88                'render_callback' => array( __CLASS__, 'render_block' ),
89                'plan_check'      => true,
90            )
91        );
92    }
93
94    /**
95     * Render the block.
96     *
97     * @param array  $attributes The block attributes.
98     * @param string $content The block content.
99     * @return string|void
100     */
101    public static function render_block( $attributes, $content ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
102        $button_type      = $attributes['buttonType'] ?? '';
103        $script_src       = $attributes['scriptSrc'] ?? '';
104        $hosted_button_id = $attributes['hostedButtonId'] ?? '';
105        $button_text      = $attributes['buttonText'] ?? '';
106
107        if ( empty( $button_type ) || empty( $hosted_button_id ) ) {
108            return;
109        }
110
111        // For stacked buttons, we need both scriptSrc and hostedButtonId
112        if ( 'stacked' === $button_type && empty( $script_src ) ) {
113            return;
114        }
115
116        // For single buttons, we need buttonText
117        if ( 'single' === $button_type && empty( $button_text ) ) {
118            return;
119        }
120
121        if ( 'stacked' === $button_type ) {
122            // Sanitize the script URL to ensure it's from an allowed PayPal domain
123            $sanitized_url = self::sanitize_paypal_script_url( $script_src );
124            if ( false === $sanitized_url ) {
125                return;
126            }
127
128            // We can't include the version number here. If we do, it is appended to the URL and causes a 400 response.
129            wp_enqueue_script( 'paypal-payment-buttons-block-head', $sanitized_url, array(), null, false ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
130            add_filter(
131                'script_loader_tag',
132                function ( $tag, $handle, $src ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
133                    if ( 'paypal-payment-buttons-block-head' === $handle ) {
134                        // Add namespace to avoid conflicts with other PayPal SDK versions
135                        if ( ! str_contains( $tag, 'data-namespace' ) ) {
136                            $tag = preg_replace( '/(\s+)src=([\'"])/', '$1 data-namespace="paypal_payment_buttons" src=$2', $tag );
137                        }
138                        // Add partner attribution ID
139                        if ( ! str_contains( $tag, 'data-paypal-partner-attribution-id' ) ) {
140                            $tag = preg_replace( '/(\s+)src=([\'"])/', '$1 data-paypal-partner-attribution-id="' . self::PAYPAL_PARTNER_ATTRIBUTION_ID . '" src=$2', $tag );
141                        }
142                    }
143                    return $tag;
144                },
145                10,
146                3
147            );
148
149            // Generate the button HTML and inline script
150            $container_id = 'paypal-container-' . $hosted_button_id;
151            $button_html  = '<div id="' . esc_attr( $container_id ) . '"></div>';
152
153            $inline_script = sprintf(
154                '(window.paypal_payment_buttons || window.paypal).HostedButtons({
155                    hostedButtonId: %s,
156                }).render(%s);',
157                wp_json_encode( $hosted_button_id, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ),
158                wp_json_encode( '#' . $container_id, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP )
159            );
160
161            wp_add_inline_script( 'paypal-payment-buttons-block-head', $inline_script );
162
163            return $button_html;
164        }
165
166        // Single button type - generate the complete form HTML
167        if ( 'single' === $button_type ) {
168            self::register_hooks();
169
170            $payment_id          = esc_attr( $hosted_button_id );
171            $button_text_escaped = esc_attr( $button_text );
172            $action_url          = esc_url( 'https://www.paypal.com/ncp/payment/' . $payment_id . '?at_code=' . self::PAYPAL_PARTNER_ATTRIBUTION_ID );
173
174            $button_html = sprintf(
175                '<style>.pp-%1$s{text-align:center;border:none;border-radius:0.25rem;min-width:11.625rem;padding:0 2rem;height:2.625rem;font-weight:bold;background-color:#FFD140;color:#000000;font-family:"Helvetica Neue",Arial,sans-serif;font-size:1rem;line-height:1.25rem;cursor:pointer;}</style>
176<div>
177<form action="%2$s" method="post" target="_blank" style="display:inline-grid;justify-items:center;align-content:start;gap:0.5rem;">
178  <input class="pp-%1$s" type="submit" value="%3$s" />
179  <img src="https://www.paypalobjects.com/images/Debit_Credit_APM.svg" alt="cards" />
180  <section style="font-size: 0.75rem;"> Powered by <img src="https://www.paypalobjects.com/paypal-ui/logos/svg/paypal-wordmark-color.svg" alt="paypal" style="height:0.875rem;vertical-align:middle;"/></section>
181</form>
182</div>',
183                $payment_id,
184                $action_url,
185                $button_text_escaped
186            );
187
188            return $button_html;
189        }
190    }
191
192    /**
193     * Load editor styles for the block.
194     * These are loaded via enqueue_block_assets to ensure proper loading in the editor iframe context.
195     */
196    public static function load_editor_styles() {
197        $handle = 'jp-paypal-payments-ncps-blocks';
198
199        Assets::register_script(
200            $handle,
201            '../../dist/paypal-payment-buttons/editor.js',
202            __FILE__,
203            array(
204                'css_path'   => '../../dist/paypal-payment-buttons/editor.css',
205                'textdomain' => 'jetpack-paypal-payments',
206            )
207        );
208        wp_enqueue_style( $handle );
209    }
210
211    /**
212     * Loads scripts
213     */
214    public static function load_editor_scripts() {
215        Assets::register_script(
216            'jp-paypal-payments-ncps-blocks',
217            '../../dist/paypal-payment-buttons/editor.js',
218            __FILE__,
219            array(
220                'in_footer'  => true,
221                'textdomain' => 'jetpack-paypal-payments',
222                'enqueue'    => true,
223                // Editor styles are loaded separately, see load_editor_styles().
224                'css_path'   => null,
225            )
226        );
227    }
228
229    /**
230     * Add display to the allowed styles.
231     *
232     * @see https://developer.wordpress.org/reference/hooks/safe_style_css/
233     *
234     * @param array $safe_styles The allowed styles.
235     * @return array The allowed styles.
236     */
237    public static function add_style_display( array $safe_styles ): array {
238        $safe_styles[] = 'display';
239        return $safe_styles;
240    }
241
242    /**
243     * Register hooks.
244     */
245    public static function register_hooks() {
246        add_filter( 'safe_style_css', array( __CLASS__, 'add_style_display' ) );
247    }
248}