Passed
Pull Request — master (#20)
by Jason
01:35
created

FoxyCart_Helper   F

Complexity

Total Complexity 66

Size/Duplication

Total Lines 496
Duplicated Lines 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 293
c 2
b 0
f 0
dl 0
loc 496
rs 3.12
wmc 66

8 Methods

Rating   Name   Duplication   Size   Complexity  
A getCartUrl() 0 3 1
A getSecret() 0 3 1
F fc_hash_querystring() 0 89 14
F fc_hash_html() 0 273 38
A __construct() 0 4 1
A setCartUrl() 0 3 1
A setSecret() 0 3 1
B fc_hash_value() 0 30 9

How to fix   Complexity   

Complex Class

Complex classes like FoxyCart_Helper often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use FoxyCart_Helper, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
use Dynamic\Foxy\Model\Foxy;
4
5
/**
6
 * FoxyCart_Helper
7
 *
8
 * @author FoxyCart.com
9
 * @copyright FoxyCart.com LLC, 2011
10
 * @version 2.0.0.20171024
11
 * @license MIT http://opensource.org/licenses/MIT
12
 * @example http://wiki.foxycart.com/docs/cart/validation
13
 *
14
 * Requirements:
15
 *   - Form "code" values should not have leading or trailing whitespace.
16
 *   - Cannot use double-pipes in an input's name
17
 *   - Empty textareas are assumed to be "open"
18
 */
19
class FoxyCart_Helper
20
{
21
22
    /**
23
     * API Key (Secret)
24
     *
25
     * @var string
26
     **/
27
    private static $secret;
28
29
    public static function setSecret($secret)
30
    {
31
        self::$secret = $secret;
32
    }
33
    public static function getSecret()
34
    {
35
        return self::$secret;
36
    }
37
38
    /**
39
     * Cart URL
40
     *
41
     * @var string
42
     * Notes: Could be 'https://yourdomain.foxycart.com/cart' or 'https://secure.yourdomain.com/cart'
43
     **/
44
    protected static $cart_url;
45
46
    public static function setCartUrl($cart_url)
47
    {
48
        self::$cart_url = $cart_url;
49
    }
50
    public static function getCartUrl()
51
    {
52
        return self::$cart_url;
53
    }
54
55
    public function __construct()
56
    {
57
        self::setCartURL(Foxy::getStoreDomain());
58
        self::setSecret(Foxy::getStoreKey());
59
    }
60
61
    /**
62
     * Cart Excludes
63
     *
64
     * Arrays of values and prefixes that should be ignored when signing links and forms.
65
     * @var array
66
     */
67
    protected static $cart_excludes = array(
68
        // Analytics values
69
        '_', '_ga', '_ke',
70
        // Cart values
71
        'cart', 'fcsid', 'empty', 'coupon', 'output', 'sub_token', 'redirect', 'callback', 'locale', 'template_set',
72
        // Checkout pre-population values
73
        'customer_email', 'customer_first_name', 'customer_last_name', 'customer_address1', 'customer_address2',
74
        'customer_city', 'customer_state', 'customer_postal_code', 'customer_country', 'customer_phone',
75
        'customer_company',
76
        'billing_first_name', 'billing_last_name', 'billing_address1', 'billing_address2',
77
        'billing_city', 'billing_postal_code', 'billing_region', 'billing_phone', 'billing_company',
78
        'shipping_first_name', 'shipping_last_name', 'shipping_address1', 'shipping_address2',
79
        'shipping_city', 'shipping_state', 'shipping_country', 'shipping_postal_code', 'shipping_region',
80
        'shipping_phone', 'shipping_company',
81
    );
82
    protected static $cart_excludes_prefixes = array(
83
        'h:', 'x:', '__', 'utm_'
84
    );
85
86
    /**
87
     * Debugging
88
     *
89
     * Set to $debug to TRUE to enable debug logging.
90
     *
91
     */
92
    protected static $debug = false;
93
    protected static $log = array();
94
95
96
    /**
97
     * "Link Method": Generate HMAC SHA256 for GET Query Strings
98
     *
99
     * Notes: Can't parse_str because PHP doesn't support non-alphanumeric characters as array keys.
100
     * @return string
101
     **/
102
    public static function fc_hash_querystring($qs, $output = true)
103
    {
104
        self::$log[] = '<strong>Signing link</strong> with data: '.htmlspecialchars(substr($qs, 0, 1500)).
105
            '...';
106
        $fail = self::$cart_url.'?'.$qs;
107
108
        // If the link appears to be hashed already, don't bother
109
        if (strpos($qs, '||')) {
110
            self::$log[] = '<strong>Link appears to be signed already</strong>: '.htmlspecialchars($code[0]);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $code does not exist. Did you maybe mean $codes?
Loading history...
111
            return $fail;
112
        }
113
114
        // Stick an ampersand on the beginning of the querystring to make matching the first element a little easier
115
        $qs = '&'.$qs;
116
117
        // Get all the prefixes, codes, and name=value pairs
118
        preg_match_all(
119
            '%(?P<amp>&(?:amp;)?)(?P<prefix>[a-z0-9]{1,3}:)?(?P<name>[^=]+)=(?P<value>[^&]+)%',
120
            $qs,
121
            $pairs,
122
            PREG_SET_ORDER
123
        );
124
        self::$log[] = 'Found the following pairs to sign:<pre>'.htmlspecialchars(print_r($pairs, true)).'</pre>';
125
126
        // Get all the "code" values, set the matches in $codes
127
        $codes = array();
128
        foreach ($pairs as $pair) {
129
            if ($pair['name'] == 'code') {
130
                $codes[$pair['prefix']] = $pair['value'];
131
            }
132
        }
133
        foreach ($pairs as $pair) {
134
            if ($pair['name'] == 'parent_code') {
135
                $codes[$pair['prefix']] .= $pair['value'];
136
            }
137
        }
138
        if (! count($codes)) {
139
            self::$log[] = '<strong style="color:#600;">No code found</strong> for the above link.';
140
            return $fail;
141
        }
142
        self::$log[] = '<strong style="color:orange;">CODES found:</strong> '.
143
            htmlspecialchars(print_r($codes, true));
144
145
        // Sign the name/value pairs
146
        foreach ($pairs as $pair) {
147
            // Skip the cart excludes
148
            $include_pair = true;
149
            if (in_array($pair['name'], self::$cart_excludes)) {
150
                $include_pair = false;
151
            }
152
            foreach (self::$cart_excludes_prefixes as $exclude_prefix) {
153
                if (substr(
154
                    strtolower($pair['prefix'].$pair['name']),
155
                    0,
156
                    strlen($exclude_prefix)
157
                ) == $exclude_prefix
158
                ) {
159
                    $include_pair = false;
160
                }
161
            }
162
            if (!$include_pair) {
163
                self::$log[] = '<strong style="color:purple;">Skipping</strong> the reserved parameter or prefix "'.
164
                    $pair['prefix'].$pair['name'].'" = '.$pair['value'];
165
                continue;
166
            }
167
            // Continue to sign the value and replace the name=value in the querystring with name=value||hash
168
            $value = self::fc_hash_value(
169
                $codes[$pair['prefix']],
170
                urldecode($pair['name']),
171
                urldecode($pair['value']),
172
                'value',
173
                false,
174
                'urlencode'
175
            );
176
            if (urldecode($pair['value']) == '--OPEN--') {
177
                $replacement = $pair['amp'].$value.'=';
178
            } else {
179
                $replacement = $pair['amp'].$pair['prefix'].urlencode($pair['name']).'='.$value;
180
            }
181
            $qs = str_replace($pair[0], $replacement, $qs);
182
            self::$log[] = 'Signed <strong>'.$pair['name'].'</strong> = <strong>'.$pair['value'].'</strong> with '.
183
                $replacement.'.<br />Replacing: '.$pair[0].'<br />With... '.$replacement;
184
        }
185
        $qs = ltrim($qs, '&'); // Get rid of that leading ampersand we added earlier
186
187
        if ($output) {
188
            echo self::$cart_url.'?'.$qs;
189
        } else {
190
            return self::$cart_url.'?'.$qs;
191
        }
192
    }
193
194
195
    /**
196
     * "Form Method": Generate HMAC SHA256 for form elements or individual <input />s
197
     *
198
     * @return string
199
     **/
200
    public static function fc_hash_value(
201
        $product_code,
202
        $option_name,
203
        $option_value = '',
204
        $method = 'name',
205
        $output = true,
206
        $urlencode = false
207
    ) {
208
        if (!$product_code || !$option_name) {
209
            return false;
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the documented return type string.
Loading history...
210
        }
211
        $option_name = str_replace(' ', '_', $option_name);
212
        if ($option_value == '--OPEN--') {
213
            $hash = hash_hmac('sha256', $product_code.$option_name.$option_value, self::$secret);
214
            $value = ($urlencode) ? urlencode($option_name).'||'.$hash.'||open' : $option_name.'||'.$hash.'||open';
215
        } else {
216
            $hash = hash_hmac('sha256', $product_code.$option_name.$option_value, self::$secret);
217
            self::$log[] = '<strong>Challenge: </strong><span style="font-family:monospace; color:blue"><code>'.
218
                $product_code.$option_name.$option_value.'</code></span>';
219
            if ($method == 'name') {
220
                $value = ($urlencode) ? urlencode($option_name).'||'.$hash : $option_name.'||'.$hash;
221
            } else {
222
                $value = ($urlencode) ? urlencode($option_value).'||'.$hash : $option_value.'||'.$hash;
223
            }
224
        }
225
226
        if ($output) {
227
            echo $value;
228
        } else {
229
            return $value;
230
        }
231
    }
232
233
    /**
234
     * Raw HTML Signing: Sign all links and form elements in a block of HTML
235
     *
236
     * Accepts a string of HTML and signs all links and forms.
237
     * Requires link 'href' and form 'action' attributes to use 'https' and not 'http'.
238
     * Requires a 'code' to be set in every form.
239
     *
240
     * @return string
241
     **/
242
    public static function fc_hash_html($html)
243
    {
244
        // Initialize some counting
245
        $count['temp'] = 0; // temp counter
0 ignored issues
show
Comprehensibility Best Practice introduced by
$count was never initialized. Although not strictly required by PHP, it is generally a good practice to add $count = array(); before regardless.
Loading history...
246
        $count['links'] = 0;
247
        $count['forms'] = 0;
248
        $count['inputs'] = 0;
249
        $count['lists'] = 0;
250
        $count['textareas'] = 0;
251
252
        // Find and sign all the links
253
        preg_match_all(
254
            '%<a .*?href=([\'"])'.preg_quote(self::$cart_url).'(?:\.php)?\?(.+?)\1.*?>%i',
255
            $html,
256
            $querystrings
257
        );
258
        self::$log[] = '<strong>Querystrings: </strong><pre>' . htmlspecialchars(print_r($querystrings, true)) .
259
            '</pre>';
260
        // print_r($querystrings);
261
        foreach ($querystrings[2] as $querystring) {
262
            // If it's already signed, skip it.
263
            if (strpos($querystring, '||')) {
264
                continue;
265
            }
266
            $pattern = '%(href=([\'"]))'.preg_quote(self::$cart_url, '%').'(?:\.php)?\?'.
267
                preg_quote($querystring, '%').'\2%i';
268
            $signed = self::fc_hash_querystring($querystring, false);
269
            $html = preg_replace($pattern, '$1'.$signed.'$2', $html, -1, $count['temp']);
270
            $count['links'] += $count['temp'];
271
        }
272
        unset($querystrings);
273
274
        // Find and sign all form values
275
        preg_match_all(
276
            '%<form [^>]*?action=[\'"]'.preg_quote(self::$cart_url).'(?:\.php)?[\'"].*?>(.+?)</form>%is',
277
            $html,
278
            $forms
279
        );
280
        foreach ($forms[1] as $form) {
281
            $count['forms']++;
282
            self::$log[] = '<strong>Signing form</strong> with data: '.
283
                htmlspecialchars(substr($form, 0, 150)).'...';
284
285
            // Store the original form so we can replace it when we're done
286
            $form_original = $form;
287
288
            // Check for the "code" input, set the matches in $codes
289
            if (!preg_match_all(
290
                '%<[^>]*?name=([\'"])([0-9]{1,3}:)?code\1[^>]*?>%i',
291
                $form,
292
                $codes,
293
                PREG_SET_ORDER
294
            )) {
295
                self::$log[] = '<strong style="color:#600;">No code found</strong> for the above form.';
296
                continue;
297
            }
298
            // For each code found, sign the appropriate inputs
299
            foreach ($codes as $code) {
300
                // If the form appears to be hashed already, don't bother
301
                if (strpos($code[0], '||')) {
302
                    self::$log[] = '<strong>Form appears to be signed already</strong>: '.htmlspecialchars($code[0]);
303
                    continue;
304
                }
305
                // Get the code and the prefix
306
                $prefix = (isset($code[2])) ? $code[2] : '';
307
                preg_match('%<[^>]*?value=([\'"])(.+?)\1[^>]*?>%i', $code[0], $code);
308
                $code = trim($code[2]);
309
                self::$log[] = '<strong>Prefix for '.htmlspecialchars($code).'</strong>: '.htmlspecialchars($prefix);
310
                if (!$code) { // If the code is empty, skip this form or specific prefixed elements
311
                    continue;
312
                }
313
314
                // Sign all <input /> elements with matching prefix
315
                preg_match_all(
316
                    '%<input [^>]*?name=([\'"])'.preg_quote($prefix).'(?![0-9]{1,3})(?:.+?)\1[^>]*>%i',
317
                    $form,
318
                    $inputs
319
                );
320
321
                // get parent codes if they exist and append them to our code
322
                $parent_code_index = false;
323
                foreach ($inputs[0] as $key => $item) {
324
                    if (strpos($item, 'parent_code') !== false) {
325
                        $parent_code_index = $key;
326
                    }
327
                }
328
                if ($parent_code_index !== false) {
329
                    if (preg_match('%value=([\'"])(.*?)\1%i', $inputs[0][$parent_code_index], $value)) {
330
                        $code .= $value[2];
331
                    }
332
                }
333
334
                foreach ($inputs[0] as $input) {
335
                    $count['inputs']++;
336
                    // Test to make sure both name and value attributes are found
337
                    if (preg_match(
338
                        '%name=([\'"])'.preg_quote($prefix).'(?![0-9]{1,3})(.+?)\1%i',
339
                        $input,
340
                        $name
341
                    ) > 0) {
342
                        preg_match('%value=([\'"])(.*?)\1%i', $input, $value);
343
                        $value = (count($value) > 0) ? $value : array('', '', '');
344
                        preg_match('%type=([\'"])(.*?)\1%i', $input, $type);
345
                        $type = (count($type) > 0) ? $type : array('', '', '');
346
                        // Skip the cart excludes
347
                        $include_input = true;
348
                        if (in_array($prefix.$name[2], self::$cart_excludes)) {
349
                            $include_input = false;
350
                        }
351
                        foreach (self::$cart_excludes_prefixes as $exclude_prefix) {
352
                            if (substr(strtolower($prefix.$name[2]), 0, strlen($exclude_prefix)) == $exclude_prefix) {
353
                                $include_input = false;
354
                            }
355
                        }
356
                        if (!$include_input) {
357
                            self::$log[] = '<strong style="color:purple;">Skipping</strong> the reserved parameter or 
358
                                prefix "'.$prefix.$name[2].'" = '.$value[2];
359
                            continue;
360
                        }
361
                        self::$log[] = '<strong>INPUT['.$type[2].']:</strong> Name: <strong>'.$prefix.
362
                            htmlspecialchars(preg_quote($name[2])).'</strong>';
363
                        $value[2] = ($value[2] == '') ? '--OPEN--' : $value[2];
364
                        if ($type[2] == 'radio') {
365
                            self::$log[] = '<strong>Replacement Pattern:</strong> ([\'"])'.
366
                                $prefix.preg_quote($value[2]).'\1';
367
                            $input_signed = preg_replace(
368
                                '%([\'"])'.preg_quote($value[2]).'\1%',
369
                                '${1}'.self::fc_hash_value($code, $name[2], $value[2], 'value', false).
370
                                "$1",
371
                                $input
372
                            );
373
                        } else {
374
                            self::$log[] = '<strong>Replacement Pattern:</strong> name=([\'"])'.
375
                                $prefix.preg_quote($name[2]).'\1';
376
                            $input_signed = preg_replace(
377
                                '%name=([\'"])'.$prefix.preg_quote($name[2]).'\1%',
378
                                'name=${1}'.$prefix.self::fc_hash_value(
379
                                    $code,
380
                                    $name[2],
381
                                    $value[2],
382
                                    'name',
383
                                    false
384
                                )."$1",
385
                                $input
386
                            );
387
                        }
388
                        self::$log[] = '<strong>INPUT:</strong> Code: <strong>'.htmlspecialchars($prefix.$code).
389
                            '</strong> :: Name: <strong>'.htmlspecialchars($prefix.$name[2]).
390
                            '</strong> :: Value: <strong>'.htmlspecialchars($value[2]).
391
                            '</strong><br />Initial input: '.htmlspecialchars($input).
392
                            '<br />Signed: <span style="color:#060;">'.htmlspecialchars($input_signed).'</span>';
393
                        $form = str_replace($input, $input_signed, $form);
394
                    }
395
                }
396
                self::$log[] = '<strong>FORM after INPUTS:</strong> <pre>'.htmlspecialchars($form).'</pre>';
397
398
                // Sign all <option /> elements
399
                preg_match_all(
400
                    '%<select [^>]*name=([\'"])'.preg_quote($prefix).'(?![0-9]{1,3})(.+?)\1[^>]*>(.+?)</select>%is',
401
                    $form,
402
                    $lists,
403
                    PREG_SET_ORDER
404
                );
405
                foreach ($lists as $list) {
406
                    $count['lists']++;
407
                    // Skip the cart excludes
408
                    $include_input = true;
409
                    if (in_array($prefix.$list[2], self::$cart_excludes)) {
410
                        $include_input = false;
411
                    }
412
                    foreach (self::$cart_excludes_prefixes as $exclude_prefix) {
413
                        if (substr(strtolower($prefix.$list[2]), 0, strlen($exclude_prefix)) == $exclude_prefix) {
414
                            $include_input = false;
415
                        }
416
                    }
417
                    if (!$include_input) {
418
                        self::$log[] = '<strong style="color:purple;">Skipping</strong> the reserved parameter or 
419
                            prefix "'.$prefix.$list[2];
420
                        continue;
421
                    }
422
                    preg_match_all(
423
                        '%<option [^>]*value=([\'"])(.+?)\1[^>]*>(?:.*?)</option>%i',
424
                        $list[0],
425
                        $options,
426
                        PREG_SET_ORDER
427
                    );
428
                    self::$log[] = '<strong>Options:</strong> <pre>'.htmlspecialchars(print_r($options, true)).'</pre>';
429
                    unset($form_part_signed);
430
                    foreach ($options as $option) {
431
                        if (!isset($form_part_signed)) {
432
                            $form_part_signed = $list[0];
433
                        }
434
                        $option_signed = preg_replace(
435
                            '%'.preg_quote($option[1]).preg_quote($option[2]).preg_quote($option[1]).'%',
436
                            $option[1].self::fc_hash_value($code, $list[2], $option[2], 'value', false).
437
                                $option[1],
438
                            $option[0]
439
                        );
440
                        $form_part_signed = str_replace($option[0], $option_signed, $form_part_signed);
441
                        self::$log[] = '<strong>OPTION:</strong> Code: <strong>'.htmlspecialchars($prefix.$code).
442
                            '</strong> :: Name: <strong>'.htmlspecialchars($prefix.$list[2]).
443
                            '</strong> :: Value: <strong>'.htmlspecialchars($option[2]).
444
                            '</strong><br />Initial option: '.htmlspecialchars($option[0]).
445
                            '<br />Signed: <span style="color:#060;">'.htmlspecialchars($option_signed).'</span>';
446
                    }
447
                    $form = str_replace($list[0], $form_part_signed, $form);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $form_part_signed does not seem to be defined for all execution paths leading up to this point.
Loading history...
448
                }
449
                self::$log[] = '<strong>FORM after OPTIONS:</strong> <pre>'.htmlspecialchars($form).'</pre>';
450
451
                // Sign all <textarea /> elements
452
                preg_match_all('%<textarea [^>]*name=([\'"])'.preg_quote($prefix).
453
                    '(?![0-9]{1,3})(.+?)\1[^>]*>(.*?)</textarea>%is', $form, $textareas, PREG_SET_ORDER);
454
                // echo "\n\nTextareas: ".print_r($textareas, true);
455
                foreach ($textareas as $textarea) {
456
                    $count['textareas']++;
457
                    // Skip the cart excludes
458
                    $include_input = true;
459
                    if (in_array($prefix.$textarea[2], self::$cart_excludes)) {
460
                        $include_input = false;
461
                    }
462
                    foreach (self::$cart_excludes_prefixes as $exclude_prefix) {
463
                        if (substr(strtolower($prefix.$textarea[2]), 0, strlen($exclude_prefix)) == $exclude_prefix) {
464
                            $include_input = false;
465
                        }
466
                    }
467
                    if (!$include_input) {
468
                        self::$log[] = '<strong style="color:purple;">Skipping</strong> the reserved parameter or 
469
                            prefix "'. $prefix.$textarea[2];
470
                        continue;
471
                    }
472
                    // Tackle implied "--OPEN--" first, if textarea is empty
473
                    $textarea[3] = ($textarea[3] == '') ? '--OPEN--' : $textarea[3];
474
                    $textarea_signed = preg_replace(
475
                        '%name=([\'"])'.preg_quote($prefix.$textarea[2]).'\1%',
476
                        "name=$1".self::fc_hash_value($code, $textarea[2], $textarea[3], 'name', false)."$1",
477
                        $textarea[0]
478
                    );
479
                    $form = str_replace($textarea[0], $textarea_signed, $form);
480
                    self::$log[] = '<strong>TEXTAREA:</strong> Code: <strong>'.htmlspecialchars($prefix.$code).
481
                        '</strong> :: Name: <strong>'.htmlspecialchars($prefix.$textarea[2]).
482
                        '</strong> :: Value: <strong>'.htmlspecialchars($textarea[3]).
483
                        '</strong><br />Initial textarea: '.htmlspecialchars($textarea[0]).
484
                        '<br />Signed: <span style="color:#060;">'.htmlspecialchars($textarea_signed).'</span>';
485
                }
486
                self::$log[] = '<strong>FORM after TEXTAREAS:</strong> <pre>'.htmlspecialchars($form).'</pre>';
487
488
                // Exclude all <button> elements
489
                $form = preg_replace(
490
                    '%<button ([^>]*)name=([\'"])(.*?)\1([^>]*>.*?</button>)%i',
491
                    "<button $1name=$2x:$3$4",
492
                    $form
493
                );
494
            }
495
            // Replace the entire form
496
            self::$log[] = '<strong>FORM after ALL:</strong> <pre>'.htmlspecialchars($form).'</pre>'.'replacing <pre>'.
497
                htmlspecialchars($form_original).'</pre>';
498
            $html = str_replace($form_original, $form, $html);
499
            self::$log[] = '<strong>FORM end</strong><hr />';
500
        }
501
502
        // Return the signed output
503
        $output = '';
504
        if (self::$debug) {
505
            self::$log['Summary'] = $count['links'].' links signed. '.$count['forms'].' forms signed. '.
506
                $count['inputs'].' inputs signed. '.$count['lists'].' lists signed. '.$count['textareas'].
507
                ' textareas signed.';
508
            $output .= '<div style="background:#fff;"><h3>FoxyCart HMAC Debugging:</h3><ul>';
509
            foreach (self::$log as $name => $value) {
510
                $output .= '<li><strong>'.$name.':</strong> '.$value.'</li>'."\n";
511
            }
512
            $output .= '</ul><hr />';
513
        }
514
        return $output.$html;
515
    }
516
}
517