Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
47.62% covered (danger)
47.62%
50 / 105
14.29% covered (danger)
14.29%
1 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayPal_Payment_Buttons
47.62% covered (danger)
47.62%
50 / 105
14.29% covered (danger)
14.29%
1 / 7
114.83
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
55.77% covered (warning)
55.77%
29 / 52
0.00% covered (danger)
0.00%
0 / 1
27.62
 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            $sanitized_url .= '?' . $parsed_url['query'];
73        }
74
75        return $sanitized_url;
76    }
77
78    /**
79     * Registers the block for use in Gutenberg
80     * This is done via an action so that we can disable
81     * registration if we need to.
82     */
83    public static function register_block() {
84        Blocks::jetpack_register_block(
85            __DIR__,
86            array(
87                'render_callback' => array( __CLASS__, 'render_block' ),
88                'plan_check'      => true,
89            )
90        );
91    }
92
93    /**
94     * Render the block.
95     *
96     * @param array  $attributes The block attributes.
97     * @param string $content The block content.
98     * @return string|void
99     */
100    public static function render_block( $attributes, $content ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
101        $button_type      = $attributes['buttonType'] ?? '';
102        $script_src       = $attributes['scriptSrc'] ?? '';
103        $hosted_button_id = $attributes['hostedButtonId'] ?? '';
104        $button_text      = $attributes['buttonText'] ?? '';
105
106        if ( empty( $button_type ) || empty( $hosted_button_id ) ) {
107            return;
108        }
109
110        // For stacked buttons, we need both scriptSrc and hostedButtonId
111        if ( 'stacked' === $button_type && empty( $script_src ) ) {
112            return;
113        }
114
115        // For single buttons, we need buttonText
116        if ( 'single' === $button_type && empty( $button_text ) ) {
117            return;
118        }
119
120        if ( 'stacked' === $button_type ) {
121            // Sanitize the script URL to ensure it's from an allowed PayPal domain
122            $sanitized_url = self::sanitize_paypal_script_url( $script_src );
123            if ( false === $sanitized_url ) {
124                return;
125            }
126
127            $script_url = esc_url( $sanitized_url );
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', $script_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}