PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / trunk
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions vtrunk
260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 111216 111220 120213 120219 120301 All 187 releases
s2member / src / includes / classes / utils-assets.inc.php

utils-assets.inc.php in s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions trunk, at src/includes/classes/utils-assets.inc.php

2,393 lines 105.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // @codingStandardsIgnoreFile
3 /**
4 * Frontend asset utilities.
5 *
6 * Copyright: © 2009-2011
7 * {@link http://websharks-inc.com/ WebSharks, Inc.}
8 * (coded in the USA)
9 *
10 * Released under the terms of the GNU General Public License.
11 * You should have received a copy of the GNU General Public License,
12 * along with this software. In the main directory, see: /licensing/
13 * If not, see: {@link http://www.gnu.org/licenses/}.
14 *
15 * @package s2Member\Utilities
16 * @since 260903.0437
17 */
18 if(!defined('WPINC'))
19 exit("Do not access this file directly.");
20
21 if(!class_exists('c_ws_plugin__s2member_utils_assets'))
22 {
23 /**
24 * Frontend asset utilities.
25 *
26 * @package s2Member\Utilities
27 * @since 260903.0437
28 */
29 class c_ws_plugin__s2member_utils_assets
30 {
31 protected static $static_asset_cache = array();
32 protected static $static_assets_location_cache = array();
33 protected static $static_assets_health_cache;
34 protected static $static_js_data_map_cache = array(); //260906.1530 Parsed shipped static JavaScript data maps, keyed by path.
35 protected static $asset_http_health_cache;
36 protected static $page_asset_expectations = array();
37
38 /**
39 * Handles CSS compression.
40 *
41 * @package s2Member\Utilities
42 * @since 3.5
43 *
44 * @param string $css A string of CSS.
45 * @return string String of CSS, after compression.
46 */
47 public static function compress_css($css = FALSE)
48 {
49 $c6 = "/(\:#| #)([A-Z0-9]{6})/i";
50 $css = preg_replace("/\/\*(.*?)\*\//s", "", $css);
51 $css = preg_replace("/[\r\n\t]+/", "", $css);
52 $css = preg_replace("/ {2,}/", " ", $css);
53 $css = preg_replace("/ , | ,|, /", ",", $css);
54 $css = preg_replace("/ \> | \>|\> /", ">", $css);
55 $css = preg_replace("/\[ /", "[", $css);
56 $css = preg_replace("/ \]/", "]", $css);
57 $css = preg_replace("/ \!\= | \!\=|\!\= /", "!=", $css);
58 $css = preg_replace("/ \|\= | \|\=|\|\= /", "|=", $css);
59 $css = preg_replace("/ \^\= | \^\=|\^\= /", "^=", $css);
60 $css = preg_replace("/ \$\= | \$\=|\$\= /", "$=", $css);
61 $css = preg_replace("/ \*\= | \*\=|\*\= /", "*=", $css);
62 $css = preg_replace("/ ~\= | ~\=|~\= /", "~=", $css);
63 $css = preg_replace("/ \= | \=|\= /", "=", $css);
64 $css = preg_replace("/ \+ | \+|\+ /", "+", $css);
65 $css = preg_replace("/ ~ | ~|~ /", "~", $css);
66 $css = preg_replace("/ \{ | \{|\{ /", "{", $css);
67 $css = preg_replace("/ \} | \}|\} /", "}", $css);
68 $css = preg_replace("/ \: | \:|\: /", ":", $css);
69 $css = preg_replace("/ ; | ;|; /", ";", $css);
70 $css = preg_replace("/;\}/", "}", $css);
71
72 return preg_replace_callback($c6, 'c_ws_plugin__s2member_utils_assets::_compress_css_c3', $css);
73 }
74
75 /**
76 * Compresses JavaScript using an s2Member-adapted implementation of JShrink 1.8.1.
77 *
78 * JShrink is Copyright (c) Robert Hafner and licensed under BSD-3-Clause.
79 * See `/src/licensing/jshrink.txt` for the complete license and attribution.
80 * @see https://github.com/tedious/JShrink
81 *
82 * @package s2Member\Utilities
83 * @since 260903.0437
84 *
85 * @param string $js JavaScript source.
86 * @return string Minified JavaScript.
87 * @throws RuntimeException If malformed JavaScript cannot be minified safely.
88 */
89 public static function compress_js($js = '')
90 {
91 $minifier = new self();
92 try
93 {
94 $js = $minifier->jshrink_lock((string)$js);
95 $js = ltrim($minifier->jshrink_minify_to_string($js, array('flaggedComments' => TRUE)));
96 $js = $minifier->jshrink_unlock($js);
97 $minifier->jshrink_clean();
98 return $js;
99 }
100 catch(Exception $e)
101 {
102 $minifier->jshrink_clean();
103 throw $e;
104 }
105 }
106
107 /**
108 * Returns the selected URL used whenever frontend CSS/JavaScript needs dynamic generation.
109 *
110 * The s2Member Dynamic Loader remains the default. If its file is missing or a trusted browser probe has confirmed that it is unreachable, the normal WordPress loader is used temporarily without changing the saved preference.
111 *
112 * @package s2Member\Utilities
113 * @since 260904.0221
114 *
115 * @param bool $force_wordpress Force the full WordPress route for a compatibility fallback.
116 * @return string Dynamic frontend asset URL without query arguments.
117 */
118 public static function dynamic_asset_url($force_wordpress = FALSE)
119 {
120 if(!$force_wordpress && (empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress')
121 && is_file(self::s2o_file_path()) && !self::asset_http_target_failed('s2o', $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url']))
122 return $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'];
123
124 return self::wordpress_dynamic_asset_url();
125 }
126
127 /**
128 * Returns the normal WordPress front-controller URL for dynamic frontend assets.
129 *
130 * @package s2Member\Utilities
131 * @since 260904.2110
132 *
133 * @return string WordPress dynamic frontend asset URL without query arguments.
134 */
135 protected static function wordpress_dynamic_asset_url()
136 {
137 global $wp_rewrite;
138
139 $index = (is_object($wp_rewrite) && !empty($wp_rewrite->index)) ? ltrim((string)$wp_rewrite->index, '/') : '';
140 if($index === '')
141 $index = 'index.php';
142 return home_url('/'.$index);
143 }
144
145 /**
146 * Returns the local s2member-o.php path.
147 *
148 * @package s2Member\Utilities
149 * @since 260904.2110
150 *
151 * @return string Local filesystem path.
152 */
153 protected static function s2o_file_path()
154 {
155 return $GLOBALS['WS_PLUGIN__']['s2member']['c']['dir'].'/'.preg_replace('/\.php$/', '-o.php', basename($GLOBALS['WS_PLUGIN__']['s2member']['l']));
156 }
157
158 /**
159 * Returns true when a trusted browser probe has confirmed that one current asset URL is unreachable.
160 *
161 * @package s2Member\Utilities
162 * @since 260904.2110
163 *
164 * @param string $id Logical health target ID.
165 * @param string $url Current public asset URL.
166 * @return bool True when the exact current URL has a recorded failure.
167 */
168 protected static function asset_http_target_failed($id = '', $url = '')
169 {
170 $health = self::asset_http_health_state();
171 return !empty($health['failures'][$id]['url']) && (string)$health['failures'][$id]['url'] === (string)$url;
172 }
173
174 /**
175 * Returns the browser-reported frontend asset health state once per PHP request.
176 *
177 * @package s2Member\Utilities
178 * @since 260904.2110
179 *
180 * @return array Stored HTTP health state.
181 */
182 protected static function asset_http_health_state()
183 {
184 if(!isset(self::$asset_http_health_cache))
185 {
186 $health = get_option('ws_plugin__s2member_asset_http_health', array());
187 self::$asset_http_health_cache = (is_array($health)) ? $health : array();
188 }
189 return self::$asset_http_health_cache;
190 }
191
192 /**
193 * Returns recent low-trust runtime suspicions reported by real frontend pages.
194 *
195 * Reports are only hints. They never change delivery by themselves. A trusted administrator-browser probe must confirm the exact URL/marker before persistent fallback or a confirmed notice is used.
196 *
197 * @package s2Member\Utilities
198 * @since 260904.2255
199 *
200 * @return array Current runtime suspicions.
201 */
202 protected static function asset_runtime_suspicions()
203 {
204 $suspicions = get_option('ws_plugin__s2member_asset_runtime_suspicions', array());
205 $suspicions = (is_array($suspicions)) ? $suspicions : array();
206 foreach($suspicions as $key => $suspicion)
207 if(empty($suspicion['reported']) || (int)$suspicion['reported'] < time() - HOUR_IN_SECONDS)
208 unset($suspicions[$key]);
209 return $suspicions;
210 }
211
212 /**
213 * Returns the current public asset URLs that an administrator's browser should probe.
214 *
215 * Normal checks are deliberately cheap. Static files use HEAD and s2member-o.php has a special early health response that exits before loading WordPress. A real-page suspicion adds a one-time full marker check for the exact asset that page expected.
216 *
217 * @package s2Member\Utilities
218 * @since 260904.2110
219 *
220 * @return array Health targets keyed by logical target ID.
221 */
222 protected static function asset_http_health_targets()
223 {
224 $targets = array();
225 if((empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress') && is_file(self::s2o_file_path()))
226 $targets['s2o'] = array(
227 'id' => 's2o',
228 'url' => $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'],
229 'probe_url' => add_query_arg('s2member_health_check', '1', $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url']),
230 'type' => 'health',
231 'mode' => 's2o-health',
232 'label' => 's2Member Dynamic Loader',
233 'failure_id' => 's2o',
234 'failure_url' => $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'],
235 );
236
237 $location = self::static_assets_location(FALSE);
238 if(!empty($location['ok']))
239 foreach(array('css' => 'static_css', 'js' => 'static_js') as $type => $option)
240 if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o'][$option]))
241 foreach(self::static_asset_ids($type, 'all') as $id)
242 {
243 $build = self::static_asset_build($id);
244 if($build <= 0)
245 continue;
246 $base = substr($id, 0, -strlen('.'.$type));
247 $url = $location['url'].'/'.$base.'-'.$build.'.'.$type;
248 if(is_file($location['dir'].'/'.$base.'-'.$build.'.'.$type))
249 $targets['static:'.$id] = array(
250 'id' => 'static:'.$id,
251 'url' => $url,
252 'probe_url' => $url,
253 'type' => $type,
254 'mode' => 'head',
255 'label' => $id,
256 'failure_id' => 'static:'.$id,
257 'failure_url' => $url,
258 );
259 }
260
261 foreach(self::asset_runtime_suspicions() as $key => $suspicion)
262 {
263 if(!self::asset_runtime_expectation_is_current($suspicion))
264 continue;
265 $id = 'runtime:'.$key;
266 $failure_id = '';
267 $failure_url = (string)$suspicion['url'];
268 if($suspicion['delivery'] === 'dynamic-lightweight')
269 {
270 $failure_id = 's2o';
271 $failure_url = $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'];
272 }
273 else if($suspicion['delivery'] === 'static' && !empty($suspicion['asset_id']))
274 $failure_id = 'static:'.$suspicion['asset_id'];
275 else
276 $failure_id = $id;
277
278 $targets[$id] = array(
279 'id' => $id,
280 'url' => (string)$suspicion['url'],
281 'probe_url' => (string)$suspicion['url'],
282 'type' => (string)$suspicion['type'],
283 'mode' => 'marker',
284 'label' => (string)$suspicion['id'],
285 'markers' => array((string)$suspicion['marker']),
286 'failure_id' => $failure_id,
287 'failure_url' => $failure_url,
288 'suspicion_key' => $key,
289 'suspicion' => $suspicion,
290 );
291 }
292 return $targets;
293 }
294
295 /**
296 * Returns a stable hash for the current browser health targets.
297 *
298 * @package s2Member\Utilities
299 * @since 260904.2110
300 *
301 * @param array $targets Current health targets.
302 * @return string Target hash.
303 */
304 protected static function asset_http_health_target_hash($targets = array())
305 {
306 $hash = array();
307 foreach((array)$targets as $id => $target)
308 $hash[$id] = array((string)$target['url'], (string)$target['type'], (string)$target['mode'], (!empty($target['markers'])) ? array_values((array)$target['markers']) : array());
309 return md5(serialize($hash));
310 }
311
312 /**
313 * Returns marker output appended to a generated static asset.
314 *
315 * @package s2Member\Utilities
316 * @since 260904.2255
317 *
318 * @param string $id Stable generated asset identifier without extension.
319 * @param string $type `css` or `js`.
320 * @param int $build Generated build timestamp.
321 * @return string Marker output.
322 */
323 protected static function static_asset_marker_output($id = '', $type = '', $build = 0)
324 {
325 $components = array();
326 if($id === 's2member-pro')
327 $components[] = 'pro';
328 else
329 {
330 $components[] = 'framework';
331 if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_assets_combine']) && (defined('WS_PLUGIN__S2MEMBER_PRO_VERSION') || isset($GLOBALS['WS_PLUGIN__']['s2member_pro'])))
332 $components[] = 'pro';
333 }
334 $token = 'static-'.(int)$build;
335 if($type === 'css')
336 {
337 $markers = array();
338 foreach($components as $component)
339 $markers[] = '#ws-plugin--s2member-'.$component.'-css-health{z-index:'.(($component === 'framework') ? '2147483641' : '2147483642').'!important}';
340 return implode('', $markers);
341 }
342 $markers = ';window.ws_plugin__s2member_asset_health=window.ws_plugin__s2member_asset_health||{};';
343 foreach($components as $component)
344 $markers .= 'window.ws_plugin__s2member_asset_health["'.$component.'_js"]="'.$token.'";';
345 return $markers;
346 }
347
348 /**
349 * Returns marker output appended to dynamically generated CSS or JavaScript.
350 *
351 * @package s2Member\Utilities
352 * @since 260904.2255
353 *
354 * @param string $type `css` or `js`.
355 * @return string Marker output.
356 */
357 public static function dynamic_asset_marker_output($type = '')
358 {
359 $type = strtolower((string)$type);
360 if(!in_array($type, array('css', 'js'), TRUE))
361 return '';
362 $token = (defined('_WS_PLUGIN__S2MEMBER_ONLY')) ? 'dynamic-lightweight' : 'dynamic-wordpress';
363 $components = array('framework');
364 if(defined('WS_PLUGIN__S2MEMBER_PRO_VERSION') || isset($GLOBALS['WS_PLUGIN__']['s2member_pro']))
365 $components[] = 'pro';
366 if($type === 'css')
367 {
368 $markers = array();
369 foreach($components as $component)
370 $markers[] = '#ws-plugin--s2member-'.$component.'-css-health{z-index:'.(($component === 'framework') ? '2147483641' : '2147483642').'!important}';
371 return "\n".implode('', $markers)."\n";
372 }
373 $markers = "\n;window.ws_plugin__s2member_asset_health=window.ws_plugin__s2member_asset_health||{};";
374 foreach($components as $component)
375 $markers .= 'window.ws_plugin__s2member_asset_health["'.$component.'_js"]="'.$token.'";';
376 return $markers."\n";
377 }
378
379 /**
380 * Registers the exact CSS/JavaScript markers expected on the current frontend page.
381 *
382 * @package s2Member\Utilities
383 * @since 260904.2255
384 *
385 * @param string $asset_id Logical static asset ID, or an empty string for dynamic delivery.
386 * @param string $type `css` or `js`.
387 * @param string $url Public URL emitted on this page.
388 * @param string $delivery `static`, `dynamic-lightweight`, or `dynamic-wordpress`.
389 * @param int $build Static build timestamp, or zero for dynamic delivery.
390 * @return null
391 */
392 public static function register_page_asset_expectations($asset_id = '', $type = '', $url = '', $delivery = '', $build = 0)
393 {
394 $type = strtolower((string)$type);
395 $url = (string)$url;
396 if(!in_array($type, array('css', 'js'), TRUE) || !$url)
397 return;
398 $components = array();
399 if($delivery === 'static')
400 {
401 if(strpos((string)$asset_id, 's2member-pro.') === 0)
402 $components[] = 'pro';
403 else
404 {
405 $components[] = 'framework';
406 if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_assets_combine']) && (defined('WS_PLUGIN__S2MEMBER_PRO_VERSION') || isset($GLOBALS['WS_PLUGIN__']['s2member_pro'])))
407 $components[] = 'pro';
408 }
409 $token = 'static-'.(int)$build;
410 }
411 else
412 {
413 $components[] = 'framework';
414 if(defined('WS_PLUGIN__S2MEMBER_PRO_VERSION') || isset($GLOBALS['WS_PLUGIN__']['s2member_pro']))
415 $components[] = 'pro';
416 $token = ($delivery === 'dynamic-lightweight') ? 'dynamic-lightweight' : 'dynamic-wordpress';
417 }
418 $recovery_url = '';
419 if($delivery !== 'dynamic-wordpress')
420 {
421 if($type === 'css')
422 $recovery_url = add_query_arg(array('ws_plugin__s2member_css' => '1', 'qcABC' => '1'), self::wordpress_dynamic_asset_url());
423 else
424 {
425 $js_value = (is_user_logged_in() && defined('WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5')) ? WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5 : '1';
426 $recovery_url = add_query_arg(array('ws_plugin__s2member_js_w_globals' => $js_value, 'qcABC' => '1'), self::wordpress_dynamic_asset_url());
427 }
428 }
429 foreach($components as $component)
430 {
431 $id = $component.'_'.$type;
432 if($type === 'css')
433 {
434 $token = ($component === 'framework') ? '2147483641' : '2147483642';
435 $marker = '#ws-plugin--s2member-'.$component.'-css-health{z-index:'.$token.'!important}';
436 }
437 else
438 $marker = 'ws_plugin__s2member_asset_health["'.$component.'_js"]="'.$token.'"';
439 $expectation = array(
440 'id' => $id,
441 'asset_id' => (string)$asset_id,
442 'type' => $type,
443 'component' => $component,
444 'url' => $url,
445 'delivery' => $delivery,
446 'token' => $token,
447 'marker' => $marker,
448 'recovery_url' => $recovery_url,
449 );
450 $expectation['signature'] = self::asset_runtime_expectation_signature($expectation);
451 self::$page_asset_expectations[$id] = $expectation;
452 }
453 return;
454 }
455
456 /**
457 * Expands one compact browser runtime expectation into the full signed structure.
458 *
459 * Frontend pages only need a few fields to check markers and recover. Reconstruct the
460 * descriptive fields here when a miss is actually reported, keeping healthy page source small.
461 *
462 * @package s2Member\Utilities
463 * @since 260905.0106
464 *
465 * @param array $compact Compact expectation fields.
466 * @return array Full expectation, or an empty array when invalid.
467 */
468 protected static function expand_asset_runtime_expectation($compact = array())
469 {
470 if(!is_array($compact) || count($compact) < 6)
471 return array();
472 $id = isset($compact[0]) ? (string)$compact[0] : '';
473 $asset_id = isset($compact[1]) ? (string)$compact[1] : '';
474 $url = isset($compact[2]) ? (string)$compact[2] : '';
475 $delivery = isset($compact[3]) ? (string)$compact[3] : '';
476 $token = isset($compact[4]) ? (string)$compact[4] : '';
477 $signature = isset($compact[5]) ? (string)$compact[5] : '';
478 if(!preg_match('/\A(framework|pro)_(css|js)\z/', $id, $match))
479 return array();
480 $component = $match[1];
481 $type = $match[2];
482 if($type === 'css')
483 $marker = '#ws-plugin--s2member-'.$component.'-css-health{z-index:'.$token.'!important}';
484 else
485 $marker = 'ws_plugin__s2member_asset_health["'.$component.'_js"]="'.$token.'"';
486
487 $recovery_url = '';
488 if($delivery !== 'dynamic-wordpress')
489 {
490 if($type === 'css')
491 $recovery_url = add_query_arg(array('ws_plugin__s2member_css' => '1', 'qcABC' => '1'), self::wordpress_dynamic_asset_url());
492 else
493 {
494 $js_value = (is_user_logged_in() && defined('WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5')) ? WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5 : '1';
495 $recovery_url = add_query_arg(array('ws_plugin__s2member_js_w_globals' => $js_value, 'qcABC' => '1'), self::wordpress_dynamic_asset_url());
496 }
497 }
498 return array(
499 'id' => $id,
500 'asset_id' => $asset_id,
501 'type' => $type,
502 'component' => $component,
503 'url' => $url,
504 'delivery' => $delivery,
505 'token' => $token,
506 'marker' => $marker,
507 'recovery_url' => $recovery_url,
508 'signature' => $signature,
509 );
510 }
511
512 /**
513 * Returns a signature for one low-trust runtime expectation report.
514 *
515 * @package s2Member\Utilities
516 * @since 260904.2255
517 *
518 * @param array $expectation Runtime expectation fields.
519 * @return string Signature.
520 */
521 protected static function asset_runtime_expectation_signature($expectation = array())
522 {
523 $parts = array();
524 foreach(array('id', 'asset_id', 'type', 'component', 'url', 'delivery', 'token', 'marker', 'recovery_url') as $key)
525 $parts[$key] = isset($expectation[$key]) ? (string)$expectation[$key] : '';
526 return hash_hmac('sha256', serialize($parts), wp_salt('nonce'));
527 }
528
529 /**
530 * Returns true when a reported expectation still describes the site's current delivery state.
531 *
532 * @package s2Member\Utilities
533 * @since 260904.2255
534 *
535 * @param array $expectation Runtime expectation.
536 * @return bool True when current.
537 */
538 protected static function asset_runtime_expectation_is_current($expectation = array())
539 {
540 if(empty($expectation['url']) || empty($expectation['delivery']) || empty($expectation['type']))
541 return FALSE;
542 if($expectation['delivery'] === 'static')
543 {
544 $id = (string)$expectation['asset_id'];
545 $type = (string)$expectation['type'];
546 if(!in_array($id, self::static_asset_ids($type, 'all'), TRUE))
547 return FALSE;
548 $build = self::static_asset_build($id);
549 $location = self::static_assets_location(FALSE);
550 $base = substr($id, 0, -strlen('.'.$type));
551 return !empty($location['ok']) && $build > 0 && (string)$expectation['url'] === $location['url'].'/'.$base.'-'.$build.'.'.$type;
552 }
553 if($expectation['delivery'] === 'dynamic-lightweight')
554 return (empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress') && strpos((string)$expectation['url'], $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'].'?') === 0;
555 if($expectation['delivery'] === 'dynamic-wordpress')
556 return strpos((string)$expectation['url'], self::wordpress_dynamic_asset_url().'?') === 0;
557 return FALSE;
558 }
559
560 /**
561 * Returns active generated frontend asset IDs for one type/component.
562 *
563 * Framework and Pro files stay separate by default. Combined mode reuses the Framework ID because that file becomes the combined representation.
564 *
565 * @package s2Member\Utilities
566 * @since 260903.1918
567 *
568 * @param string $type `css` or `js`.
569 * @param string $component `all`, `framework`, or `pro`.
570 * @return array Logical generated filenames.
571 */
572 public static function static_asset_ids($type = '', $component = 'all')
573 {
574 $type = strtolower((string)$type);
575 $component = strtolower((string)$component);
576 if(!in_array($type, array('css', 'js'), TRUE) || !in_array($component, array('all', 'framework', 'pro'), TRUE))
577 return array();
578
579 $framework = 's2member.'.$type;
580 $pro = (!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_assets_combine'])) ? $framework : 's2member-pro.'.$type;
581 if($component === 'framework')
582 return array($framework);
583 if($component === 'pro')
584 return array($pro);
585
586 $ids = array($framework);
587 if((defined('WS_PLUGIN__S2MEMBER_PRO_VERSION') || isset($GLOBALS['WS_PLUGIN__']['s2member_pro'])) && $pro !== $framework)
588 $ids[] = $pro;
589 return $ids;
590 }
591
592 /**
593 * Returns how s2Member text used by static JavaScript should be delivered.
594 *
595 * @package s2Member\Utilities
596 * @since 260906.2049
597 *
598 * @return string `static` to include text in generated JavaScript, or `page` to load it with each WordPress page.
599 */
600 public static function static_js_text_delivery()
601 {
602 return (!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_js_text']) && $GLOBALS['WS_PLUGIN__']['s2member']['o']['static_js_text'] === 'page') ? 'page' : 'static';
603 }
604
605 /**
606 * Determines whether page-loaded JavaScript text is supported by the active Framework/Pro combination.
607 *
608 * Framework can always load its own text with the page. Pro explicitly advertises support because
609 * older Pro releases predate the shipped static-JS data map needed for this delivery mode.
610 *
611 * @package s2Member\Utilities
612 * @since 260906.2049
613 *
614 * @return bool True if page-loaded JavaScript text is available.
615 */
616 public static function static_js_page_text_supported()
617 {
618 if(!c_ws_plugin__s2member_utils_conds::pro_is_installed())
619 return TRUE;
620 return (bool)apply_filters('ws_plugin__s2member_static_js_page_text_supported', FALSE);
621 }
622
623 /**
624 * Determines whether the active Pro JavaScript hook contains only built-in callbacks safe to cache in a static file.
625 *
626 * This lets a newer Framework retain static-file delivery with older Pro releases that predate
627 * the static-source filters. Unknown, reordered, or deprecated gateway callbacks remain dynamic.
628 *
629 * @package s2Member\Utilities
630 * @since 260906.2049
631 *
632 * @return bool True if the active Pro hook can be captured safely.
633 */
634 protected static function static_js_builtin_pro_callbacks_supported()
635 {
636 if(!c_ws_plugin__s2member_utils_conds::pro_is_installed() || has_filter('ws_plugin__s2member_pro_available_gateways'))
637 return FALSE;
638 $built_ins = array(
639 'c_ws_plugin__s2member_pro_css_js::js_w_globals',
640 'c_ws_plugin__s2member_pro_paypal_css_js::paypal_js_w_globals',
641 'c_ws_plugin__s2member_pro_stripe_css_js::stripe_js_w_globals',
642 'c_ws_plugin__s2member_pro_authnet_css_js::authnet_js_w_globals',
643 'c_ws_plugin__s2member_pro_clickbank_css_js::clickbank_js_w_globals',
644 );
645 $callbacks = isset($GLOBALS['wp_filter']['ws_plugin__s2member_during_js_w_globals']) ? $GLOBALS['wp_filter']['ws_plugin__s2member_during_js_w_globals'] : array();
646 if(is_object($callbacks) && isset($callbacks->callbacks))
647 $callbacks = $callbacks->callbacks;
648 $found = FALSE;
649 foreach((array)$callbacks as $priority => $priority_callbacks)
650 foreach((array)$priority_callbacks as $callback)
651 {
652 if((int)$priority !== 10 || !is_array($callback) || !isset($callback['function'], $callback['accepted_args']) || !in_array($callback['function'], $built_ins, TRUE) || (int)$callback['accepted_args'] !== 1)
653 return FALSE;
654 $found = TRUE;
655 }
656 return $found;
657 }
658
659 /**
660 * Captures built-in Pro JavaScript from an older Pro release for static-file text delivery.
661 *
662 * @package s2Member\Utilities
663 * @since 260906.2049
664 *
665 * @return string Captured Pro JavaScript, or an empty string if the hook is not safely cacheable.
666 */
667 protected static function static_js_builtin_pro_output()
668 {
669 if(!self::static_js_builtin_pro_callbacks_supported())
670 return '';
671 //260906.2256 Match the template variables passed by the normal dynamic loader when capturing older Pro callbacks.
672 $u = $GLOBALS['WS_PLUGIN__']['s2member']['c']['dir_url'];
673 $i = $u.'/src/images';
674 ob_start();
675 do_action('ws_plugin__s2member_during_js_w_globals', get_defined_vars());
676 return (string)ob_get_clean();
677 }
678
679 /**
680 * Returns shipped static JavaScript data maps used by one generated static JavaScript file.
681 *
682 * Framework owns its data map. Pro appends its independent data map through a filter so the two
683 * release packages never need cross-repo slot coordination.
684 *
685 * @package s2Member\Utilities
686 * @since 260906.0738
687 *
688 * @param string $id Logical generated JavaScript filename.
689 * @return array Data-map paths keyed by the compact browser-data namespace.
690 */
691 protected static function static_js_data_map_paths($id = '')
692 {
693 if(self::static_js_text_delivery() !== 'page')
694 return array();
695 $id = strtolower((string)$id);
696 $paths = array();
697 if($id === 's2member.js')
698 $paths['f'] = $GLOBALS['WS_PLUGIN__']['s2member']['c']['dir'].'/src/includes/s2member.js.php';
699 $paths = (array)apply_filters('ws_plugin__s2member_static_js_data_map_paths', $paths, $id, get_defined_vars());
700 foreach($paths as $key => $path)
701 if(!preg_match('/^[a-z][a-z0-9_]*$/i', (string)$key) || !(string)$path)
702 unset($paths[$key]);
703 return $paths;
704 }
705
706 /**
707 * Parses one shipped static JavaScript data map into an exact expression-to-slot lookup.
708 *
709 * @package s2Member\Utilities
710 * @since 260906.0738
711 *
712 * @param string $path Data-map path.
713 * @return array Parse result.
714 */
715 protected static function static_js_data_map($path = '')
716 {
717 $path = (string)$path;
718 if(isset(self::$static_js_data_map_cache[$path]))
719 return self::$static_js_data_map_cache[$path];
720 if(!$path || !is_readable($path) || ($source = file_get_contents($path)) === FALSE)
721 return self::$static_js_data_map_cache[$path] = array('ok' => FALSE, 'slots' => array(), 'hash' => '', 'error' => 'Static JavaScript data map is not readable: '.$path);
722
723 $slots = array();
724 if(!preg_match_all('/\\$data\\[(\\d+)\\]\\s*=\\s*\\/\\*d\\*\\/(.*?)\\/\\*b\\*\\/;/s', $source, $matches, PREG_SET_ORDER))
725 return self::$static_js_data_map_cache[$path] = array('ok' => FALSE, 'slots' => array(), 'hash' => '', 'error' => 'Static JavaScript data map contains no marked entries: '.$path);
726 foreach($matches as $index => $match)
727 {
728 $slot = (int)$match[1];
729 $expression = trim((string)$match[2]);
730 if($slot !== $index || !$expression || isset($slots[$expression]))
731 return self::$static_js_data_map_cache[$path] = array('ok' => FALSE, 'slots' => array(), 'hash' => '', 'error' => 'Static JavaScript data-map slots are invalid or duplicated: '.$path);
732 $slots[$expression] = $slot;
733 }
734 return self::$static_js_data_map_cache[$path] = array('ok' => TRUE, 'slots' => $slots, 'hash' => hash('sha256', $source), 'error' => '');
735 }
736
737 /**
738 * Returns the current shipped data-map signature for one static JavaScript representation.
739 *
740 * @package s2Member\Utilities
741 * @since 260906.0738
742 *
743 * @param string $id Logical generated JavaScript filename.
744 * @return array Signature result.
745 */
746 protected static function static_js_data_map_signature($id = '')
747 {
748 $hashes = array();
749 foreach(self::static_js_data_map_paths($id) as $key => $path)
750 {
751 $data_map = self::static_js_data_map($path);
752 if(empty($data_map['ok']))
753 return array('ok' => FALSE, 'signature' => '', 'error' => (string)$data_map['error']);
754 $hashes[(string)$key] = (string)$data_map['hash'];
755 }
756 if(!$hashes)
757 return array('ok' => FALSE, 'signature' => '', 'error' => 'No static JavaScript data map is available for '.$id);
758 return array('ok' => TRUE, 'signature' => hash('sha256', wp_json_encode($hashes)), 'error' => '');
759 }
760
761 /**
762 * Returns the data-map signature saved with the active generated JavaScript file.
763 *
764 * @package s2Member\Utilities
765 * @since 260906.0738
766 *
767 * @param string $id Logical generated JavaScript filename.
768 * @return string Saved signature.
769 */
770 protected static function static_asset_data_map_signature($id = '')
771 {
772 $signatures = get_option('ws_plugin__s2member_static_asset_data_map_signatures', array());
773 return (is_array($signatures) && isset($signatures[$id])) ? (string)$signatures[$id] : '';
774 }
775
776 /**
777 * Saves the data-map signature paired with one generated JavaScript file.
778 *
779 * @package s2Member\Utilities
780 * @since 260906.0738
781 *
782 * @param string $id Logical generated JavaScript filename.
783 * @param string $signature Current data-map signature.
784 * @return null
785 */
786 protected static function set_static_asset_data_map_signature($id = '', $signature = '')
787 {
788 if(!in_array($id, array('s2member.js', 's2member-pro.js'), TRUE))
789 return;
790 $signatures = get_option('ws_plugin__s2member_static_asset_data_map_signatures', array());
791 $signatures = is_array($signatures) ? $signatures : array();
792 $signatures[$id] = (string)$signature;
793 update_option('ws_plugin__s2member_static_asset_data_map_signatures', $signatures);
794 return;
795 }
796
797 /**
798 * Returns the signed build timestamp for one generated frontend asset file.
799 *
800 * Positive values are current. Negative values preserve the previous timestamp while marking that exact file stale.
801 *
802 * @package s2Member\Utilities
803 * @since 260903.0525
804 *
805 * @param string $id Logical generated filename, e.g. `s2member.js` or `s2member-pro.css`.
806 * @return int Signed build timestamp.
807 */
808 public static function static_asset_build($id = '')
809 {
810 $id = strtolower((string)$id);
811 if(in_array($id, array('css', 'js'), TRUE))
812 $id = 's2member.'.$id;
813 $builds = get_option('ws_plugin__s2member_static_asset_builds', array());
814 return (in_array($id, array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js'), TRUE) && is_array($builds) && isset($builds[$id])) ? (int)$builds[$id] : 0;
815 }
816
817 /**
818 * Updates the signed build timestamp for one generated frontend asset file.
819 *
820 * @package s2Member\Utilities
821 * @since 260903.0525
822 *
823 * @param string $id Logical generated filename.
824 * @param int $build Signed build timestamp.
825 * @return null
826 */
827 protected static function set_static_asset_build($id = '', $build = 0)
828 {
829 $id = strtolower((string)$id);
830 if(!in_array($id, array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js'), TRUE))
831 return;
832 $builds = get_option('ws_plugin__s2member_static_asset_builds', array());
833 $builds = is_array($builds) ? $builds : array();
834 //260903.1918 Build state is keyed by the actual logical generated filename; old type-only beta keys are discarded on the next successful state write.
835 $builds = array_intersect_key($builds, array_flip(array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js')));
836 $builds[$id] = (int)$build;
837 update_option('ws_plugin__s2member_static_asset_builds', $builds);
838 unset(self::$static_asset_cache[$id]);
839 self::$static_assets_health_cache = NULL;
840 delete_option('ws_plugin__s2member_static_asset_health');
841 return;
842 }
843
844 /**
845 * Clears generated frontend asset build state when the active file representation changes.
846 *
847 * Existing timestamped files remain on disk for already-cached HTML; fresh requests generate only the newly active representation.
848 *
849 * @package s2Member\Utilities
850 * @since 260903.1918
851 *
852 * @return null
853 */
854 protected static function reset_static_asset_builds()
855 {
856 delete_option('ws_plugin__s2member_static_asset_builds');
857 delete_option('ws_plugin__s2member_static_asset_data_map_signatures'); //260906.1530 Static JavaScript and its data-map slot layout must stay synchronized.
858 delete_option('ws_plugin__s2member_static_asset_health');
859 self::$static_asset_cache = array();
860 self::$static_js_data_map_cache = array();
861 self::$static_assets_health_cache = NULL;
862 foreach(array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js') as $id)
863 delete_transient('ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id));
864 return;
865 }
866
867 /**
868 * Invalidates selected generated frontend assets.
869 *
870 * Selectors may be `css`, `js`, `framework_css`, `framework_js`, `pro_css`, `pro_js`, or exact logical generated filenames.
871 * Existing files remain available for already-cached HTML. Current pages stop referencing a stale generation until its replacement succeeds.
872 *
873 * @package s2Member\Utilities
874 * @since 260903.0437
875 *
876 * @param array|string $assets Asset selectors.
877 * @return null
878 */
879 public static function invalidate_static_assets($assets = array('css', 'js'))
880 {
881 $assets = is_array($assets) ? $assets : array($assets);
882 $ids = array();
883 foreach($assets as $asset)
884 {
885 $asset = strtolower((string)$asset);
886 if(in_array($asset, array('css', 'js'), TRUE))
887 $ids = array_merge($ids, self::static_asset_ids($asset, 'all'));
888 else if(preg_match('/^(framework|pro)_(css|js)$/', $asset, $match))
889 $ids = array_merge($ids, self::static_asset_ids($match[2], $match[1]));
890 else if(in_array($asset, array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js'), TRUE))
891 $ids[] = $asset;
892 }
893 foreach(array_unique($ids) as $id)
894 {
895 $build = self::static_asset_build($id);
896 //260903.1918 Preserve an existing file's timestamp while marking only that file stale; never create build-state entries for files that have not yet been generated.
897 if($build)
898 self::set_static_asset_build($id, -abs($build));
899 else
900 unset(self::$static_asset_cache[$id]);
901 delete_transient('ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id));
902 }
903 return;
904 }
905
906 /**
907 * Invalidates generated assets when s2Member or WordPress values rendered into them change.
908 *
909 * @package s2Member\Utilities
910 * @since 260903.0437
911 *
912 * @param string $option Option name.
913 * @param mixed $old_value Previous value.
914 * @param mixed $value New value.
915 * @return null
916 */
917 public static function maybe_invalidate_after_wp_option_update($option = '', $old_value = NULL, $value = NULL)
918 {
919 if($old_value === $value)
920 return;
921
922 if((string)$option === 'ws_plugin__s2member_options')
923 {
924 if(!is_array($old_value) || !is_array($value))
925 return;
926 $old = (array)$old_value;
927 $new = (array)$value;
928
929 //260907.2203 Keep a concise operational history of CSS/JS configuration changes when s2Member logging is enabled.
930 $config_changes = array();
931 foreach(array('dynamic_asset_loader', 'static_css', 'static_css_minify', 'static_js', 'static_js_text', 'static_js_minify', 'static_assets_combine') as $key)
932 if(serialize(isset($old[$key]) ? $old[$key] : NULL) !== serialize(isset($new[$key]) ? $new[$key] : NULL))
933 $config_changes[$key] = array('old' => isset($old[$key]) ? $old[$key] : NULL, 'new' => isset($new[$key]) ? $new[$key] : NULL);
934 if($config_changes)
935 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'CSS/JS configuration changed', 'changes' => $config_changes));
936
937 if((string)(isset($old['static_assets_combine']) ? $old['static_assets_combine'] : '0') !== (string)(isset($new['static_assets_combine']) ? $new['static_assets_combine'] : '0'))
938 {
939 //260903.1918 A combine-mode change changes what the s2member.* filenames represent; discard all build state so the new 2-file/4-file representation starts with fresh timestamps.
940 self::reset_static_asset_builds();
941 return;
942 }
943
944 $keys = array(
945 'css' => array('static_css', 'static_css_minify'),
946 'js' => array('static_js', 'static_js_text', 'static_js_minify'),
947 'framework_js' => array('custom_reg_force_personal_emails', 'custom_reg_password_min_length', 'custom_reg_password_min_strength'),
948 'pro_css' => array('pro_gateways_enabled'),
949 'pro_js' => array(
950 'pro_gateways_enabled', 'pro_stripe_api_publishable_key', 'pro_stripe_api_image', 'pro_stripe_api_allow_remember_me',
951 'paypal_checkout_enable', 'paypal_checkout_sandbox', 'paypal_checkout_client_id', 'paypal_checkout_sandbox_client_id', 'sec_encryption_key',
952 ),
953 );
954 $keys = (array)apply_filters('ws_plugin__s2member_static_asset_option_keys', $keys, get_defined_vars());
955 $invalidate = array();
956 foreach($keys as $selector => $option_keys)
957 foreach((array)$option_keys as $key)
958 if((isset($old[$key]) || isset($new[$key])) && serialize(isset($old[$key]) ? $old[$key] : NULL) !== serialize(isset($new[$key]) ? $new[$key] : NULL))
959 {
960 $invalidate[] = $selector;
961 break;
962 }
963 if($invalidate)
964 self::invalidate_static_assets($invalidate);
965 return;
966 }
967 if(in_array((string)$option, array('siteurl', 'home'), TRUE))
968 self::invalidate_static_assets(array('css', 'js'));
969 else if((string)$option === 'WPLANG' && self::static_js_text_delivery() !== 'page')
970 self::invalidate_static_assets('js');
971 return;
972 }
973
974 /**
975 * Invalidates generated frontend assets when plugin activation/deactivation can change frontend integrations.
976 *
977 * @package s2Member\Utilities
978 * @since 260903.0437
979 *
980 * @return null
981 */
982 public static function invalidate_after_plugin_change($plugin = '')
983 {
984 $plugin = (string)$plugin;
985 if($plugin === 's2member-pro/s2member-pro.php')
986 self::reset_static_asset_builds();
987 else if($plugin === 'buddypress/bp-loader.php')
988 self::invalidate_static_assets('framework_js');
989 return;
990 }
991
992 /**
993 * Invalidates generated frontend assets after plugin/translation upgrades.
994 *
995 * @package s2Member\Utilities
996 * @since 260903.0437
997 *
998 * @param object $upgrader WordPress upgrader instance.
999 * @param array $options Upgrade details.
1000 * @return null
1001 */
1002 public static function maybe_invalidate_after_upgrade($upgrader = NULL, $options = array())
1003 {
1004 if(!is_array($options) || empty($options['type']))
1005 return;
1006 if($options['type'] === 'translation')
1007 {
1008 if(self::static_js_text_delivery() !== 'page')
1009 self::invalidate_static_assets('js'); //260906.2049 Page-loaded JavaScript text follows the current translation without rebuilding the external static file.
1010 return;
1011 }
1012 if($options['type'] !== 'plugin')
1013 return;
1014
1015 $plugins = array();
1016 if(!empty($options['plugin']))
1017 $plugins[] = (string)$options['plugin'];
1018 if(!empty($options['plugins']) && is_array($options['plugins']))
1019 $plugins = array_merge($plugins, $options['plugins']);
1020 foreach(array_unique($plugins) as $plugin)
1021 {
1022 if($plugin === 's2member/s2member.php')
1023 self::invalidate_static_assets(array('framework_css', 'framework_js'));
1024 else if($plugin === 's2member-pro/s2member-pro.php')
1025 self::invalidate_static_assets(array('pro_css', 'pro_js'));
1026 else if($plugin === 'buddypress/bp-loader.php')
1027 self::invalidate_static_assets('framework_js');
1028 }
1029 return;
1030 }
1031
1032 /**
1033 * Returns one current generated frontend asset URL, building it when stale/uninitialized.
1034 *
1035 * Active timestamped files are existence-checked before their URLs are emitted. A missing or
1036 * trusted-browser-confirmed unreachable file returns a failure so callers can use dynamic delivery immediately.
1037 *
1038 * @package s2Member\Utilities
1039 * @since 260903.0525
1040 *
1041 * @param string $id Logical generated filename.
1042 * @param bool $force Force a new build timestamp immediately.
1043 * @return array Result with `ok`, `url`, `build`, and `error` keys.
1044 */
1045 public static function ensure_static_asset($id = '', $force = FALSE)
1046 {
1047 $id = strtolower((string)$id);
1048 if(in_array($id, array('css', 'js'), TRUE))
1049 $id = 's2member.'.$id;
1050 if(!in_array($id, array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js'), TRUE))
1051 return array('ok' => FALSE, 'url' => '', 'build' => 0, 'error' => 'Invalid static asset ID');
1052 $type = substr(strrchr($id, '.'), 1);
1053 if(!in_array($id, self::static_asset_ids($type, 'all'), TRUE))
1054 return array('ok' => FALSE, 'url' => '', 'build' => 0, 'error' => 'Static asset is not active in the current delivery mode');
1055 if(!$force && isset(self::$static_asset_cache[$id]))
1056 return self::$static_asset_cache[$id];
1057
1058 //260903.0544 Normal requests only check whether current hooks/configuration permit static delivery; source assembly and filesystem work wait until a build is actually needed.
1059 $compatibility = self::static_asset_definition($id, FALSE);
1060 if(empty($compatibility['ok']))
1061 return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => abs(self::static_asset_build($id)), 'error' => (string)$compatibility['error']);
1062
1063 $state = self::static_asset_build($id);
1064 $dirty = $state < 0;
1065 $active_build = abs($state);
1066 $base = substr($id, 0, -strlen('.'.$type));
1067 $data_map_signature = array('ok' => TRUE, 'signature' => '', 'error' => '');
1068 $uses_data_map = $type === 'js' && self::static_js_text_delivery() === 'page';
1069 if($uses_data_map)
1070 {
1071 $data_map_signature = self::static_js_data_map_signature($id);
1072 if(empty($data_map_signature['ok']))
1073 return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => (string)$data_map_signature['error']);
1074 //260906.1530 Regenerate static JavaScript when its shipped data-map layout changes so slot numbers stay synchronized.
1075 if($active_build && self::static_asset_data_map_signature($id) !== (string)$data_map_signature['signature'])
1076 {
1077 $dirty = TRUE;
1078 $state = -$active_build;
1079 self::set_static_asset_build($id, $state);
1080 }
1081 }
1082 if(!$force && !$dirty && $active_build)
1083 {
1084 $location = self::static_assets_location(FALSE);
1085 if(empty($location['ok']))
1086 return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => $location['error']);
1087 $url = $location['url'].'/'.$base.'-'.$active_build.'.'.$type;
1088 $path = $location['dir'].'/'.$base.'-'.$active_build.'.'.$type;
1089 //260904.2110 A few local file checks are cheaper than sending a broken static URL. Missing or browser-confirmed unreachable files fall back to dynamic delivery immediately.
1090 if(!is_file($path))
1091 return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => 'Expected static asset '.$id.' is missing.');
1092 if(self::asset_http_target_failed('static:'.$id, $url))
1093 return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => 'Static asset '.$id.' could not be loaded from its public URL.');
1094 return self::$static_asset_cache[$id] = array('ok' => TRUE, 'url' => $url, 'build' => $active_build, 'error' => '');
1095 }
1096
1097 $failure_key = 'ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id);
1098 if(!$force && $dirty && ($failure = get_transient($failure_key)))
1099 return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => (string)$failure);
1100
1101 $definition = self::static_asset_definition($id, TRUE);
1102 if(empty($definition['ok']))
1103 return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => (string)$definition['error']);
1104
1105 $build = max(time(), $active_build + 1);
1106 $result = self::build_static_asset($base, $build, $type, $definition['sources'], !empty($definition['minify']));
1107 if(!empty($result['ok']))
1108 {
1109 if($uses_data_map)
1110 self::set_static_asset_data_map_signature($id, (string)$data_map_signature['signature']);
1111 self::set_static_asset_build($id, $build);
1112
1113 //260907.2203 Record successful generation so automatic and manual rebuilds remain visible later.
1114 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array(
1115 'event' => 'Static CSS/JS asset generated', 'result' => 'success', 'asset' => $id, 'build' => $build,
1116 'trigger' => $force ? 'forced refresh' : 'automatic generation', 'minified' => !empty($definition['minify']), 'url' => $result['url'],
1117 ));
1118
1119 //260905.0106 Prune only after the new timestamp is current so the previous generation is treated as stale instead of protected.
1120 self::prune_static_asset_generations(dirname($result['path']), $result['path']);
1121 delete_transient($failure_key);
1122 return self::$static_asset_cache[$id] = array('ok' => TRUE, 'url' => $result['url'], 'build' => $build, 'error' => '');
1123 }
1124 set_transient($failure_key, (string)$result['error'], 5 * MINUTE_IN_SECONDS);
1125
1126 //260907.2203 Preserve failed generation details even when delivery later falls back or recovers automatically.
1127 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array(
1128 'event' => 'Static CSS/JS asset generation failed', 'result' => 'failure', 'asset' => $id, 'attempted_build' => $build,
1129 'previous_build' => $active_build, 'trigger' => $force ? 'forced refresh' : 'automatic generation', 'error' => (string)$result['error'],
1130 ));
1131
1132 return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => $result['error']);
1133 }
1134
1135 /**
1136 * Returns all active generated frontend assets for one type.
1137 *
1138 * If any active file cannot be generated safely, callers fall back to the legacy dynamic asset for the entire type rather than mixing static and dynamic representations.
1139 *
1140 * @package s2Member\Utilities
1141 * @since 260903.1918
1142 *
1143 * @param string $type `css` or `js`.
1144 * @param bool $force Force fresh timestamps for every active file of this type.
1145 * @return array Aggregate result with individual assets keyed by logical filename.
1146 */
1147 public static function ensure_static_assets($type = '', $force = FALSE)
1148 {
1149 $type = strtolower((string)$type);
1150 if(!in_array($type, array('css', 'js'), TRUE))
1151 return array('ok' => FALSE, 'assets' => array(), 'error' => 'Invalid static asset type');
1152 $option = 'static_'.$type;
1153 if(empty($GLOBALS['WS_PLUGIN__']['s2member']['o'][$option]))
1154 return array('ok' => FALSE, 'assets' => array(), 'error' => 'Static '.strtoupper($type).' Delivery is disabled');
1155
1156 $assets = array();
1157 foreach(self::static_asset_ids($type, 'all') as $id)
1158 {
1159 $assets[$id] = self::ensure_static_asset($id, $force);
1160 if(empty($assets[$id]['ok']))
1161 return array('ok' => FALSE, 'assets' => $assets, 'error' => (string)$assets[$id]['error']);
1162 }
1163 return array('ok' => (bool)$assets, 'assets' => $assets, 'error' => '');
1164 }
1165
1166 /**
1167 * Refreshes all currently enabled static frontend asset types immediately.
1168 *
1169 * CSS and JS remain independent; within each type only files active in the current separate/combined representation are rebuilt.
1170 *
1171 * @package s2Member\Utilities
1172 * @since 260903.0525
1173 *
1174 * @return array Results keyed by `css` and/or `js`.
1175 */
1176 public static function refresh_static_assets()
1177 {
1178 $results = array();
1179 foreach(array('css' => 'static_css', 'js' => 'static_js') as $type => $option)
1180 if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o'][$option]))
1181 {
1182 foreach(self::static_asset_ids($type, 'all') as $id)
1183 {
1184 unset(self::$static_asset_cache[$id]);
1185 delete_transient('ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id));
1186 }
1187 $results[$type] = self::ensure_static_assets($type, TRUE);
1188 }
1189 return $results;
1190 }
1191
1192 /**
1193 * AJAX handler for the General Options “Refresh Static Assets” button.
1194 *
1195 * @package s2Member\Utilities
1196 * @since 260903.0437
1197 *
1198 * @return null Exits through WordPress JSON helpers.
1199 */
1200 public static function ajax_refresh_static_assets()
1201 {
1202 check_ajax_referer('ws-plugin--s2member-refresh-static-assets');
1203 if(!current_user_can('create_users'))
1204 wp_send_json_error(array('message' => 'You do not have permission to refresh s2Member static assets.'), 403);
1205
1206 $results = self::refresh_static_assets();
1207 if(!$results)
1208 wp_send_json_error(array('message' => 'Enable Static CSS Delivery or Static JS Delivery and save the options first.'), 400);
1209
1210 $success = $errors = array();
1211 foreach($results as $type => $result)
1212 if(!empty($result['ok']))
1213 $success[] = strtoupper($type);
1214 else
1215 $errors[] = strtoupper($type).': '.((!empty($result['error'])) ? $result['error'] : 'unknown build error');
1216
1217 if(!$errors)
1218 {
1219 //260907.2203 Record the administrator-triggered refresh result.
1220 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'Manual Static CSS/JS refresh', 'result' => 'success', 'refreshed' => $success));
1221
1222 wp_send_json_success(array('message' => 'Static '.implode(' + ', $success).' refreshed. New timestamped files are active.'));
1223 }
1224
1225 //260907.2203 Record partial and failed administrator-triggered refreshes too.
1226 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'Manual Static CSS/JS refresh', 'result' => ($success ? 'partial failure' : 'failure'), 'refreshed' => $success, 'errors' => $errors));
1227
1228 $message = (($success) ? 'Refreshed '.implode(' + ', $success).'. ' : '').'Could not refresh '.implode('; ', $errors).'. Failed types continue with their previous valid files when still current, or dynamic delivery when stale.';
1229 wp_send_json_error(array('message' => $message), 500);
1230 }
1231
1232 /**
1233 * Checks the few currently active generated files for local filesystem availability.
1234 *
1235 * This runs on administrator requests and is also mirrored by the per-file check immediately before a static frontend URL is used. With at most four active generated files, direct existence checks avoid stale health results with a small, bounded cost.
1236 *
1237 * @package s2Member\Utilities
1238 * @since 260903.0525
1239 *
1240 * @param bool $force Recheck even if this request already has a cached result.
1241 * @return array Missing active asset files keyed by logical filename.
1242 */
1243 public static function static_assets_health($force = FALSE)
1244 {
1245 if(!$force && isset(self::$static_assets_health_cache))
1246 return self::$static_assets_health_cache;
1247 if(empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_css']) && empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_js']))
1248 return self::$static_assets_health_cache = array();
1249
1250 $missing = array();
1251 $location = self::static_assets_location(FALSE);
1252 if(empty($location['ok']))
1253 $missing['location'] = $location['error'];
1254 else
1255 foreach(array('css' => 'static_css', 'js' => 'static_js') as $type => $option)
1256 if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o'][$option]))
1257 foreach(self::static_asset_ids($type, 'all') as $id)
1258 {
1259 $build = self::static_asset_build($id);
1260 $base = substr($id, 0, -strlen('.'.$type));
1261 if($build > 0 && !is_file($location['dir'].'/'.$base.'-'.$build.'.'.$type))
1262 $missing[$id] = 'Expected static asset '.$id.' is missing.';
1263 }
1264
1265 //260907.2203 Log only local-health transitions so recurring admin checks do not repeat the same event.
1266 $previous = get_option('ws_plugin__s2member_static_asset_health', array());
1267 $previous = is_array($previous) ? $previous : array();
1268 if(serialize($previous) !== serialize($missing))
1269 {
1270 update_option('ws_plugin__s2member_static_asset_health', $missing, FALSE);
1271 if($missing)
1272 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'Static CSS/JS local health issue', 'result' => 'failure', 'issues' => $missing));
1273 else if($previous)
1274 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'Static CSS/JS local health recovered', 'result' => 'recovered', 'previous_issues' => $previous));
1275 }
1276
1277 return self::$static_assets_health_cache = $missing;
1278 }
1279
1280 /**
1281 * Displays a branded admin warning for missing or browser-confirmed unreachable frontend assets.
1282 *
1283 * @package s2Member\Utilities
1284 * @since 260903.0612
1285 *
1286 * @attaches-to ``add_action('admin_notices');``
1287 * @return null
1288 */
1289 public static function static_assets_admin_notice()
1290 {
1291 if(!current_user_can('create_users') || (defined('DOING_AJAX') && DOING_AJAX))
1292 return;
1293
1294 $messages = array();
1295 $static_settings_url = add_query_arg('s2member-open-panel', 'frontend-static-assets', admin_url('/admin.php?page=ws-plugin--s2member-gen-ops')).'#ws-plugin--s2member-static-assets';
1296 $dynamic_settings_url = add_query_arg('s2member-open-panel', 'dynamic-asset-loader', admin_url('/admin.php?page=ws-plugin--s2member-gen-ops')).'#ws-plugin--s2member-dynamic-asset-loader-section';
1297 $health = self::static_assets_health();
1298 if($health)
1299 $messages[] = esc_html(implode(' ', $health)).' Pages that need the missing file are using dynamic delivery instead. <a href="'.esc_url($static_settings_url).'">Open Static CSS/JS Optimization and refresh the static assets.</a>';
1300
1301 $using_s2o = empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress';
1302 $s2o_missing = $using_s2o && !is_file(self::s2o_file_path());
1303
1304 //260907.2203 Track missing/recovered loader transitions without logging every admin health check.
1305 $s2o_missing_logged = (bool)get_option('ws_plugin__s2member_css_js_s2o_missing', FALSE);
1306 if($s2o_missing && !$s2o_missing_logged)
1307 {
1308 update_option('ws_plugin__s2member_css_js_s2o_missing', 1, FALSE);
1309 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 's2Member Dynamic Loader file missing', 'result' => 'failure', 'file' => self::s2o_file_path(), 'fallback' => 'WordPress Dynamic Loader'));
1310 }
1311 else if($using_s2o && !$s2o_missing && $s2o_missing_logged)
1312 {
1313 delete_option('ws_plugin__s2member_css_js_s2o_missing');
1314 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 's2Member Dynamic Loader file recovered', 'result' => 'recovered', 'file' => self::s2o_file_path()));
1315 }
1316
1317 if($s2o_missing)
1318 $messages[] = 'The selected s2Member Dynamic Loader file <code>s2member-o.php</code> is missing. s2Member is using the WordPress Dynamic Loader instead. Restore the file or <a href="'.esc_url($dynamic_settings_url).'">choose the WordPress Dynamic Loader</a>.';
1319
1320 $http_health = self::asset_http_health_state();
1321 $has_confirmed_failure = (bool)($health || $s2o_missing);
1322 if(is_array($http_health) && !empty($http_health['runtime_warnings']) && is_array($http_health['runtime_warnings']))
1323 foreach($http_health['runtime_warnings'] as $warning)
1324 if(!empty($warning['reported']) && (int)$warning['reported'] >= time() - HOUR_IN_SECONDS)
1325 $messages[] = 'A real frontend page reported that <code>'.esc_html((string)$warning['id']).'</code> did not become active, even though a follow-up browser check could load the expected file and marker. This can indicate script/style optimization, execution order, a browser extension, or another runtime conflict. Delivery has not been changed automatically.';
1326 if(is_array($http_health) && !empty($http_health['failures']) && is_array($http_health['failures']))
1327 foreach($http_health['failures'] as $id => $failure)
1328 {
1329 $status = (!empty($failure['status'])) ? ' HTTP '.(int)$failure['status'].'.' : '';
1330 if($id === 's2o' && $using_s2o && !$s2o_missing && !empty($failure['url']) && (string)$failure['url'] === (string)$GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'])
1331 {
1332 $has_confirmed_failure = TRUE;
1333 $messages[] = 'The selected s2Member Dynamic Loader could not be reached.'.$status.' s2Member is using the WordPress Dynamic Loader instead. <a href="'.esc_url($dynamic_settings_url).'">Review Dynamic CSS/JS Loader</a> or see <a href="https://s2member.com/kb-article/mod-security-odd-403-503-500-errors/">Mod Security (Odd 403, 503, 500 Errors)</a>.';
1334 }
1335 else if(strpos((string)$id, 'static:') === 0 && !empty($failure['url']) && self::asset_http_target_failed($id, (string)$failure['url']))
1336 {
1337 $has_confirmed_failure = TRUE;
1338 $messages[] = 'A generated static file could not be loaded from its public URL.'.$status.' Pages that need it are using dynamic delivery instead. <a href="'.esc_url($static_settings_url).'">Open Static CSS/JS Optimization</a>.';
1339 }
1340 else if(strpos((string)$id, 'runtime:') === 0 && !empty($http_health['checked']) && (int)$http_health['checked'] >= time() - HOUR_IN_SECONDS)
1341 {
1342 $has_confirmed_failure = TRUE;
1343 $messages[] = 'A dynamically generated frontend asset could not be loaded or did not contain its expected completion marker.'.$status.' Review the browser console and your CSS/JavaScript optimization or security settings.';
1344 }
1345 }
1346
1347 if($messages)
1348 c_ws_plugin__s2member_admin_notices::display_branded_notice('s2Member Frontend Asset Notice', implode('<br /><br />', $messages), $has_confirmed_failure);
1349 return;
1350 }
1351
1352 /**
1353 * Prints an infrequent trusted browser-side reachability probe for active frontend assets.
1354 *
1355 * Healthy static files use HEAD. The lightweight loader uses its tiny pre-WordPress health mode.
1356 * A frontend runtime suspicion forces one full cache-busted marker check for that exact URL.
1357 *
1358 * @package s2Member\Utilities
1359 * @since 260904.2110
1360 *
1361 * @attaches-to ``add_action('admin_footer');``
1362 * @attaches-to ``add_action('wp_footer');``
1363 * @return null
1364 */
1365 public static function asset_http_health_probe()
1366 {
1367 if(!current_user_can('create_users') || (defined('DOING_AJAX') && DOING_AJAX))
1368 return;
1369 $targets = self::asset_http_health_targets();
1370 if(!$targets)
1371 return;
1372
1373 $target_hash = self::asset_http_health_target_hash($targets);
1374 $health = self::asset_http_health_state();
1375 $has_failures = is_array($health) && !empty($health['failures']);
1376 $has_suspicions = (bool)self::asset_runtime_suspicions();
1377 $interval = ($has_failures || $has_suspicions) ? MINUTE_IN_SECONDS : 10 * MINUTE_IN_SECONDS;
1378 if(!$has_suspicions && is_array($health) && !empty($health['checked']) && !empty($health['target_hash']) && (string)$health['target_hash'] === $target_hash && (int)$health['checked'] >= time() - $interval)
1379 return;
1380
1381 $config = array(
1382 'targets' => array_values($targets),
1383 'target_hash' => $target_hash,
1384 'ajax_url' => admin_url('admin-ajax.php'),
1385 'nonce' => wp_create_nonce('ws-plugin--s2member-asset-http-health'),
1386 'reload_on_change' => is_admin(),
1387 );
1388 echo '<script type="text/javascript">(function(c){if(!window.fetch||!window.URL||!window.Promise)return;function u(t,i){var x=new URL(t.probe_url,window.location.href),n=Date.now().toString(36)+"-"+i+"-"+Math.random().toString(36).slice(2);x.searchParams.set("s2member_asset_health",n);if(t.mode==="s2o-health")x.searchParams.set("s2member_health_token",n);return{x:x.toString(),n:n}}function ct(r,t){var v=(r.headers.get("content-type")||"").toLowerCase();if(t.type==="css")return v.indexOf("text/css")!==-1;if(t.type==="js")return /(javascript|ecmascript)/.test(v);return v.indexOf("text/plain")!==-1}function f(t,m,i,body){var z=u(t,i);return fetch(z.x,{method:m,cache:"no-store",credentials:"same-origin",headers:{"Cache-Control":"no-cache, no-store, max-age=0","Pragma":"no-cache"}}).then(function(r){var h=r.headers.get("x-s2member-health-token")||"",tm=r.headers.get("x-s2member-health-time")||"";if(!body)return{ok:r.ok&&ct(r,t),status:r.status,content_type:r.headers.get("content-type")||"",text:"",token:z.n,health_token:h,health_time:tm};return r.text().then(function(x){return{ok:r.ok&&ct(r,t),status:r.status,content_type:r.headers.get("content-type")||"",text:x,token:z.n,health_token:h,health_time:tm}})}).catch(function(){return{ok:false,status:0,content_type:"",text:"",token:z.n,health_token:"",health_time:""}})}function p(t,i){if(t.mode==="s2o-health")return f(t,"GET",i,true).then(function(r){r.ok=r.ok&&r.health_token===r.token&&r.health_time!==""&&r.text.indexOf("s2member-o-health:"+r.token+":"+r.health_time)===0;return{id:t.id,ok:r.ok,status:r.status,content_type:r.content_type,detail:r.ok?"":"Health marker mismatch"}});if(t.mode==="marker")return f(t,"GET",i,true).then(function(r){if(r.ok&&t.markers)for(var j=0;j<t.markers.length;j++)if(r.text.indexOf(t.markers[j])===-1){r.ok=false;break}return{id:t.id,ok:r.ok,status:r.status,content_type:r.content_type,detail:r.ok?"":"Expected marker missing"}});return f(t,"HEAD",i,false).then(function(r){if(r.ok)return{id:t.id,ok:true,status:r.status,content_type:r.content_type,detail:""};return f(t,"GET",i+"g",false).then(function(g){return{id:t.id,ok:g.ok,status:g.status,content_type:g.content_type,detail:g.ok?"":"Public URL check failed"}})})}Promise.all(c.targets.map(p)).then(function(results){var body="action="+encodeURIComponent("ws_plugin__s2member_asset_http_health")+"&_ajax_nonce="+encodeURIComponent(c.nonce)+"&target_hash="+encodeURIComponent(c.target_hash)+"&results="+encodeURIComponent(JSON.stringify(results));return fetch(c.ajax_url,{method:"POST",cache:"no-store",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded;charset=UTF-8","Cache-Control":"no-cache, no-store, max-age=0","Pragma":"no-cache"},body:body})}).then(function(r){return r.json()}).then(function(j){if(c.reload_on_change&&j&&j.success&&j.data&&j.data.reload)window.location.reload()}).catch(function(){})})('.wp_json_encode($config).');</script>' . "\n";
1389 return;
1390 }
1391
1392 /**
1393 * Stores trusted administrator-browser asset probe results.
1394 *
1395 * @package s2Member\Utilities
1396 * @since 260904.2110
1397 *
1398 * @return null Exits through WordPress JSON helpers.
1399 */
1400 public static function ajax_asset_http_health_report()
1401 {
1402 check_ajax_referer('ws-plugin--s2member-asset-http-health');
1403 if(!current_user_can('create_users'))
1404 wp_send_json_error(array('message' => 'You do not have permission to report s2Member asset health.'), 403);
1405
1406 $targets = self::asset_http_health_targets();
1407 $target_hash = self::asset_http_health_target_hash($targets);
1408 if(empty($_POST['target_hash']) || (string)wp_unslash($_POST['target_hash']) !== $target_hash)
1409 wp_send_json_success(array('stale' => TRUE, 'reload' => FALSE));
1410
1411 $results = (!empty($_POST['results'])) ? json_decode(wp_unslash($_POST['results']), TRUE) : array();
1412 $by_id = array();
1413 if(is_array($results))
1414 foreach($results as $result)
1415 if(is_array($result) && !empty($result['id']))
1416 $by_id[(string)$result['id']] = $result;
1417
1418 $old = self::asset_http_health_state();
1419 $old_failures = (is_array($old) && !empty($old['failures']) && is_array($old['failures'])) ? $old['failures'] : array();
1420 $runtime_warnings = (is_array($old) && !empty($old['runtime_warnings']) && is_array($old['runtime_warnings'])) ? $old['runtime_warnings'] : array();
1421 foreach($runtime_warnings as $key => $warning)
1422 if(empty($warning['reported']) || (int)$warning['reported'] < time() - HOUR_IN_SECONDS)
1423 unset($runtime_warnings[$key]);
1424
1425 $old_runtime_warnings = $runtime_warnings; //260907.2203 Preserve prior warning state so only new trusted transitions are logged.
1426
1427 $failures = array();
1428 $suspicions = self::asset_runtime_suspicions();
1429
1430 foreach($targets as $id => $target)
1431 {
1432 $result = (isset($by_id[$id]) && is_array($by_id[$id])) ? $by_id[$id] : array();
1433 if(empty($result['ok']))
1434 {
1435 $failure_id = (!empty($target['failure_id'])) ? (string)$target['failure_id'] : $id;
1436 $failures[$failure_id] = array(
1437 'url' => (!empty($target['failure_url'])) ? (string)$target['failure_url'] : (string)$target['url'],
1438 'label' => (string)$target['label'],
1439 'status' => (!empty($result['status'])) ? (int)$result['status'] : 0,
1440 'content_type' => (!empty($result['content_type'])) ? substr(sanitize_text_field((string)$result['content_type']), 0, 100) : '',
1441 'detail' => (!empty($result['detail'])) ? substr(sanitize_text_field((string)$result['detail']), 0, 160) : '',
1442 );
1443 }
1444 else if(!empty($target['suspicion_key']) && !empty($target['suspicion']))
1445 {
1446 $key = (string)$target['suspicion_key'];
1447 $runtime_warnings[$key] = array(
1448 'id' => (string)$target['suspicion']['id'],
1449 'url' => (string)$target['suspicion']['url'],
1450 'delivery' => (string)$target['suspicion']['delivery'],
1451 'reported' => time(),
1452 );
1453 }
1454 if(!empty($target['suspicion_key']))
1455 unset($suspicions[(string)$target['suspicion_key']]);
1456 }
1457
1458 update_option('ws_plugin__s2member_asset_runtime_suspicions', $suspicions, FALSE);
1459
1460 //260907.2203 Keep an operational history of newly confirmed failures, recoveries, and runtime warnings.
1461 foreach($failures as $id => $failure)
1462 if(!isset($old_failures[$id]) || serialize($old_failures[$id]) !== serialize($failure))
1463 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array(
1464 'event' => 'CSS/JS delivery health failure', 'result' => 'failure', 'target' => $id, 'details' => $failure,
1465 'fallback' => ($id === 's2o') ? 'WordPress Dynamic Loader' : ((strpos((string)$id, 'static:') === 0) ? 'dynamic delivery' : 'none'),
1466 ));
1467 foreach($old_failures as $id => $failure)
1468 if(!isset($failures[$id]))
1469 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'CSS/JS delivery health recovered', 'result' => 'recovered', 'target' => $id, 'previous_details' => $failure));
1470 foreach($runtime_warnings as $key => $warning)
1471 if(!isset($old_runtime_warnings[$key]))
1472 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'CSS/JS runtime warning confirmed', 'result' => 'warning', 'details' => $warning, 'delivery_changed' => FALSE));
1473
1474 $new_health = array('checked' => time(), 'target_hash' => $target_hash, 'failures' => $failures, 'runtime_warnings' => $runtime_warnings);
1475 update_option('ws_plugin__s2member_asset_http_health', $new_health, FALSE);
1476 self::$asset_http_health_cache = $new_health;
1477 wp_send_json_success(array('failures' => count($failures), 'runtime_warnings' => count($runtime_warnings), 'reload' => serialize($old_failures) !== serialize($failures)));
1478 }
1479
1480 /**
1481 * Prints the late real-page asset marker monitor.
1482 *
1483 * A normal browser has already finished loading ordinary CSS/JavaScript by window.load, so a short extra grace period is enough to avoid racing normal delivery. Healthy pages make no request. A recoverable miss uses one WordPress fallback request that also carries compact signed diagnostic details.
1484 *
1485 * @package s2Member\Utilities
1486 * @since 260904.2255
1487 *
1488 * @attaches-to ``add_action('wp_footer');``
1489 * @return null
1490 */
1491 public static function page_asset_runtime_monitor()
1492 {
1493 if(is_admin() || !self::$page_asset_expectations)
1494 return;
1495
1496 $expectations = array();
1497 $recovery = array('css' => '', 'js' => '');
1498 foreach(self::$page_asset_expectations as $expectation)
1499 {
1500 $expectations[] = array(
1501 (string)$expectation['id'],
1502 (string)$expectation['asset_id'],
1503 (string)$expectation['url'],
1504 (string)$expectation['delivery'],
1505 (string)$expectation['token'],
1506 (string)$expectation['signature'],
1507 );
1508 if(!empty($expectation['recovery_url']) && empty($recovery[(string)$expectation['type']]))
1509 $recovery[(string)$expectation['type']] = (string)$expectation['recovery_url'];
1510 }
1511 $config = array('a' => admin_url('admin-ajax.php'), 'e' => $expectations, 'r' => $recovery, 'd' => 1000);
1512 echo '<script type="text/javascript" id="ws-plugin--s2member-asset-runtime-monitor">(function(c){function t(e){return /_js$/.test(e[0])?"js":"css"}function p(e){return /^pro_/.test(e[0])?"pro":"framework"}function n(e){var i="ws-plugin--s2member-"+p(e)+"-css-health",o=document.getElementById(i);if(!o){o=document.createElement("span");o.id=i;o.style.cssText="position:absolute;left:-99999px;top:-99999px;width:1px;height:1px;visibility:hidden";(document.body||document.documentElement).appendChild(o)}return o}function ok(e){if(t(e)==="js")return !!(window.ws_plugin__s2member_asset_health&&window.ws_plugin__s2member_asset_health[p(e)+"_js"]===e[4]);return !window.getComputedStyle||String(getComputedStyle(n(e)).zIndex)===e[4]}function u(x,m){var q=Date.now().toString(36)+"-"+Math.random().toString(36).slice(2),j=JSON.stringify(m);if(!window.URL)return x+(x.indexOf("?")<0?"?":"&")+"s2member_asset_recovery="+encodeURIComponent(q)+"&s2member_asset_runtime_suspect="+encodeURIComponent(j);var o=new URL(x,location.href);o.searchParams.set("s2member_asset_recovery",q);o.searchParams.set("s2member_asset_runtime_suspect",j);return o.toString()}function report(m){if(!window.fetch||!m.length)return;fetch(c.a,{method:"POST",cache:"no-store",credentials:"same-origin",keepalive:true,headers:{"Content-Type":"application/x-www-form-urlencoded;charset=UTF-8"},body:"action=ws_plugin__s2member_asset_runtime_suspect&missing="+encodeURIComponent(JSON.stringify(m))}).catch(function(){})}function recover(m){var ro=[],cm=m.filter(function(e){return t(e)==="css"}),ja=c.e.filter(function(e){return t(e)==="js"}),jm=m.filter(function(e){return t(e)==="js"});if(cm.length){if(c.r.css){var l=document.createElement("link");l.rel="stylesheet";l.href=u(c.r.css,cm);l.onerror=function(){report(cm)};document.head.appendChild(l)}else ro=ro.concat(cm)}if(jm.length){if(ja.length&&jm.length===ja.length&&c.r.js){var s=document.createElement("script");s.src=u(c.r.js,jm);s.async=false;s.onerror=function(){report(jm)};(document.body||document.documentElement).appendChild(s)}else ro=ro.concat(jm)}if(ro.length)report(ro)}function check(){var m=c.e.filter(function(e){return !ok(e)});if(m.length)recover(m)}c.e.filter(function(e){return t(e)==="css"}).forEach(n);function go(){setTimeout(check,c.d)}document.readyState==="complete"?go():addEventListener("load",go,false)})('.wp_json_encode($config).');</script>' . "\n";
1513 return;
1514 }
1515
1516 /**
1517 * Records signed low-trust frontend runtime suspicions without changing delivery state.
1518 *
1519 * Reports are rate-limited and only force a later trusted administrator-browser confirmation. A recovery request and the standalone AJAX reporter share this validator so successful page-local fallback normally needs no separate reporting request.
1520 *
1521 * @package s2Member\Utilities
1522 * @since 260905.0009
1523 *
1524 * @param array $missing Missing runtime expectations.
1525 * @return int Number of newly recorded suspicions.
1526 */
1527 protected static function record_asset_runtime_suspicions($missing = array())
1528 {
1529 if(!is_array($missing) || !$missing)
1530 return 0;
1531 $missing = array_slice($missing, 0, 4);
1532 $suspicions = self::asset_runtime_suspicions();
1533 $recorded = 0;
1534 foreach($missing as $expectation)
1535 {
1536 if(is_array($expectation) && isset($expectation[0]) && !isset($expectation['id']))
1537 $expectation = self::expand_asset_runtime_expectation($expectation);
1538 if(!is_array($expectation) || empty($expectation['signature']))
1539 continue;
1540 $signature = (string)$expectation['signature'];
1541 unset($expectation['signature']);
1542 if(!hash_equals(self::asset_runtime_expectation_signature($expectation), $signature) || !self::asset_runtime_expectation_is_current($expectation))
1543 continue;
1544 $key = md5((string)$expectation['id']."\0".(string)$expectation['url']."\0".(string)$expectation['marker']);
1545 if(get_transient('ws_plugin__s2member_asset_runtime_suspect_'.$key))
1546 continue;
1547
1548 $first_report = empty($suspicions[$key]); //260907.2203 Avoid duplicating the same low-trust runtime suspicion in the log.
1549
1550 set_transient('ws_plugin__s2member_asset_runtime_suspect_'.$key, 1, MINUTE_IN_SECONDS);
1551 $expectation['reported'] = time();
1552 $expectation['signature'] = $signature;
1553 $suspicions[$key] = $expectation;
1554
1555 //260907.2203 Record the first frontend runtime suspicion for later troubleshooting and trusted confirmation.
1556 if($first_report)
1557 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array(
1558 'event' => 'Frontend CSS/JS runtime issue reported', 'result' => 'suspected', 'asset' => (string)$expectation['id'],
1559 'delivery' => (string)$expectation['delivery'], 'url' => (string)$expectation['url'], 'trusted_confirmation_pending' => TRUE,
1560 ));
1561
1562 $recorded++;
1563 }
1564 if($recorded)
1565 update_option('ws_plugin__s2member_asset_runtime_suspicions', $suspicions, FALSE);
1566 return $recorded;
1567 }
1568
1569 /**
1570 * Records signed runtime suspicions carried by a page-local WordPress recovery request.
1571 *
1572 * @package s2Member\Utilities
1573 * @since 260905.0009
1574 *
1575 * @return int Number of newly recorded suspicions.
1576 */
1577 public static function record_asset_runtime_recovery_suspicion()
1578 {
1579 if(empty($_GET['s2member_asset_runtime_suspect']))
1580 return 0;
1581 $missing = json_decode(wp_unslash($_GET['s2member_asset_runtime_suspect']), TRUE);
1582 return self::record_asset_runtime_suspicions($missing);
1583 }
1584
1585 /**
1586 * Records a low-trust frontend runtime suspicion without changing delivery state.
1587 *
1588 * This endpoint remains available for misses where loading a full fallback could duplicate JavaScript that already ran.
1589 *
1590 * @package s2Member\Utilities
1591 * @since 260904.2255
1592 *
1593 * @return null Exits through WordPress JSON helpers.
1594 */
1595 public static function ajax_asset_runtime_suspicion()
1596 {
1597 $missing = (!empty($_POST['missing'])) ? json_decode(wp_unslash($_POST['missing']), TRUE) : array();
1598 wp_send_json_success(array('recorded' => self::record_asset_runtime_suspicions($missing)));
1599 }
1600
1601 /**
1602 * Returns the source definition for one currently enabled/compatible generated frontend asset file.
1603 *
1604 * @package s2Member\Utilities
1605 * @since 260903.0525
1606 *
1607 * @param string $id Logical generated filename.
1608 * @param bool $include_sources Build ordered source definitions only when an asset actually needs generation.
1609 * @return array Definition result.
1610 */
1611 protected static function static_asset_definition($id = '', $include_sources = TRUE)
1612 {
1613 $id = strtolower((string)$id);
1614 if(!in_array($id, array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js'), TRUE))
1615 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Invalid static asset ID');
1616 $type = substr(strrchr($id, '.'), 1);
1617 $pro_file = strpos($id, 's2member-pro.') === 0;
1618 $combine = !$pro_file && !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_assets_combine']);
1619 $o = $GLOBALS['WS_PLUGIN__']['s2member']['o'];
1620 $c = $GLOBALS['WS_PLUGIN__']['s2member']['c'];
1621 if($pro_file && !in_array($id, self::static_asset_ids($type, 'all'), TRUE))
1622 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Separate Pro static asset is not active');
1623
1624 if($type === 'css')
1625 {
1626 if(empty($o['static_css']))
1627 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Static CSS Delivery is disabled');
1628
1629 //260903.0729 Framework-level dynamic requirements are authoritative; Pro may narrow only the generic during-CSS hook requirement when all callbacks are known static-compatible built-ins.
1630 $framework_dynamic = has_action('ws_plugin__s2member_before_css') || isset($GLOBALS['wp_filter']['all']);
1631 $hook_dynamic = has_action('ws_plugin__s2member_during_css');
1632 $hook_dynamic = (bool)apply_filters('ws_plugin__s2member_dynamic_css_required', $hook_dynamic, get_defined_vars());
1633
1634 if($framework_dynamic || $hook_dynamic)
1635 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Current CSS hooks require legacy dynamic assets');
1636 if(!$include_sources)
1637 return array('ok' => TRUE, 'sources' => array(), 'minify' => !empty($o['static_css_minify']), 'error' => '');
1638
1639 $sources = ($pro_file) ? array() : array(array('file' => $c['dir'].'/src/includes/s2member.css', 'preserve_header' => TRUE));
1640 if(!$pro_file)
1641 $sources = (array)apply_filters('ws_plugin__s2member_static_css_sources', $sources, get_defined_vars());
1642 if($pro_file || $combine)
1643 $sources = (array)apply_filters('ws_plugin__s2member_static_pro_css_sources', $sources, get_defined_vars());
1644 if(!$sources)
1645 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'No static CSS sources are available for '.$id);
1646 return array('ok' => TRUE, 'sources' => $sources, 'minify' => !empty($o['static_css_minify']), 'error' => '');
1647 }
1648 if($type === 'js')
1649 {
1650 if(empty($o['static_js']))
1651 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Static JS Delivery is disabled');
1652 if(!function_exists('wp_add_inline_script'))
1653 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Static JS requires WordPress 4.5+');
1654
1655 $page_text = self::static_js_text_delivery() === 'page';
1656 if($page_text && !self::static_js_page_text_supported())
1657 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Loading JavaScript text with each WordPress page requires a current s2Member Pro version');
1658
1659 if($page_text)
1660 {
1661 //260906.2049 Text and other page-specific values resolve in the normal HTML request, so they do not make the external JavaScript dynamic.
1662 $framework_dynamic = apply_filters('ws_plugin__s2member_js_api_constants_enable', FALSE)
1663 || has_action('ws_plugin__s2member_before_js_w_globals') || isset($GLOBALS['wp_filter']['all']);
1664 }
1665 else
1666 {
1667 $site_locale = (string)get_option('WPLANG');
1668 if(!$site_locale && defined('WPLANG'))
1669 $site_locale = (string)WPLANG;
1670 $site_locale = ($site_locale) ? $site_locale : 'en_US';
1671 $current_locale = (function_exists('determine_locale')) ? (string)determine_locale() : (string)get_locale();
1672 $framework_dynamic = apply_filters('ws_plugin__s2member_js_api_constants_enable', FALSE)
1673 || has_action('ws_plugin__s2member_before_js_w_globals') || $current_locale !== $site_locale || has_filter('ws_plugin__s2member_files_dir')
1674 || has_filter('ws_plugin__s2member_min_password_length') || has_filter('ws_plugin__s2member_min_password_strength_code') || has_filter('ws_plugin__s2member_min_password_strength_score')
1675 || isset($GLOBALS['wp_filter']['all']);
1676 }
1677
1678 $hook_dynamic = has_action('ws_plugin__s2member_during_js_w_globals');
1679 $hook_dynamic = (bool)apply_filters('ws_plugin__s2member_dynamic_js_required', $hook_dynamic, get_defined_vars());
1680 if($hook_dynamic && !$page_text && self::static_js_builtin_pro_callbacks_supported())
1681 $hook_dynamic = FALSE; //260906.2049 Older Pro releases can still use static-file text when their JavaScript hook contains only known built-ins.
1682
1683 if($framework_dynamic || $hook_dynamic)
1684 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Current JavaScript hooks/configuration require dynamic assets');
1685 if($page_text)
1686 {
1687 $data_map_signature = self::static_js_data_map_signature($id);
1688 if(empty($data_map_signature['ok']))
1689 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => (string)$data_map_signature['error']);
1690 }
1691 if(!$include_sources)
1692 return array('ok' => TRUE, 'sources' => array(), 'minify' => !empty($o['static_js_minify']), 'error' => '');
1693
1694 $sources = ($pro_file) ? array() : array(
1695 array('file' => $c['dir'].'/src/includes/jquery/jquery.sprintf/jquery.sprintf.js', 'preserve_header' => TRUE),
1696 ($page_text)
1697 ? array('file' => $c['dir'].'/src/includes/s2member.js', 'data_map' => $c['dir'].'/src/includes/s2member.js.php', 'data_key' => 'f')
1698 : array('file' => $c['dir'].'/src/includes/s2member.js', 'render' => TRUE),
1699 );
1700 if(!$pro_file)
1701 $sources = (array)apply_filters('ws_plugin__s2member_static_js_sources', $sources, get_defined_vars());
1702 if($pro_file || $combine)
1703 {
1704 $source_count = count($sources);
1705 $sources = (array)apply_filters('ws_plugin__s2member_static_pro_js_sources', $sources, get_defined_vars());
1706 if(!$page_text && c_ws_plugin__s2member_utils_conds::pro_is_installed() && count($sources) === $source_count)
1707 {
1708 $pro_output = self::static_js_builtin_pro_output();
1709 if($pro_output === '')
1710 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'No compatible static JavaScript source is available from the installed s2Member Pro version');
1711 $sources[] = array('contents' => $pro_output);
1712 }
1713 }
1714 if(!$sources)
1715 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'No static JavaScript sources are available for '.$id);
1716 return array('ok' => TRUE, 'sources' => $sources, 'minify' => !empty($o['static_js_minify']), 'error' => '');
1717 }
1718 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Invalid static asset type');
1719 }
1720
1721 /**
1722 * Replaces marked PHP interpolations in a canonical JavaScript source with compact data-map slots.
1723 *
1724 * The current canonical sources place every marked interpolation inside a single-quoted JavaScript
1725 * string. Replacing only the PHP block with `'+d[n]+'` preserves that historical string coercion.
1726 *
1727 * @package s2Member\Utilities
1728 * @since 260906.0738
1729 *
1730 * @param string $source Canonical mixed JS/PHP source.
1731 * @param string $data_map_path Shipped data-map path.
1732 * @param string $data_key Browser namespace key (`f` or `p`).
1733 * @return array Transform result.
1734 */
1735 protected static function static_js_data_source($source = '', $data_map_path = '', $data_key = '')
1736 {
1737 $data_map = self::static_js_data_map($data_map_path);
1738 if(empty($data_map['ok']))
1739 return array('ok' => FALSE, 'source' => '', 'error' => (string)$data_map['error']);
1740 if(!preg_match('/^[a-z][a-z0-9_]*$/i', (string)$data_key))
1741 return array('ok' => FALSE, 'source' => '', 'error' => 'Invalid JavaScript data namespace key');
1742 $slots = $data_map['slots'];
1743 $errors = array();
1744 $source = preg_replace_callback('/<\\?php.*?\\?>/s', function($match) use ($slots, &$errors) {
1745 if(!preg_match('/\\/\\*d\\*\\/(.*?)\\/\\*b\\*\\//s', $match[0], $data_match))
1746 {
1747 $errors[] = 'An unmarked PHP interpolation remains in a static JavaScript data source';
1748 return $match[0];
1749 }
1750 $expression = trim((string)$data_match[1]);
1751 if(!isset($slots[$expression]))
1752 {
1753 $errors[] = 'A marked JavaScript expression is missing from its shipped data map';
1754 return $match[0];
1755 }
1756 return "'+d[".(int)$slots[$expression]."]+'";
1757 }, (string)$source);
1758 if($errors || strpos($source, '<?php') !== FALSE || strpos($source, '?>') !== FALSE)
1759 return array('ok' => FALSE, 'source' => '', 'error' => ($errors) ? implode('; ', array_unique($errors)) : 'PHP remained after static JavaScript data transformation');
1760 //260906.0738 Keep the short alias lexical to this source so Framework and Pro slots cannot overwrite one another in separate or combined files.
1761 return array('ok' => TRUE, 'source' => "(function(d){\n".$source."\n})(window.s2_data.".$data_key.");", 'error' => '');
1762 }
1763
1764 /**
1765 * Includes one trusted shipped static JavaScript data map in normal WordPress page context.
1766 *
1767 * @package s2Member\Utilities
1768 * @since 260906.0738
1769 *
1770 * @param string $path Data-map path.
1771 * @param array|null $keys Optional stable slot IDs; NULL evaluates every value in the data map.
1772 * @return array|false Data-map values, or FALSE on failure.
1773 */
1774 protected static function load_static_js_data_map($path = '', $keys = NULL)
1775 {
1776 if(!$path || !is_readable($path))
1777 return FALSE;
1778 $s2_data_keys = (is_array($keys)) ? array_fill_keys(array_map('intval', $keys), TRUE) : NULL;
1779 $data = include $path;
1780 return (is_array($data)) ? $data : FALSE;
1781 }
1782
1783 /**
1784 * Returns page-local JavaScript data for the active generated static files.
1785 *
1786 * Complete data maps are emitted for now. TO-DO: pass page-specific sparse slot sets once feature requirements can be determined safely.
1787 *
1788 * @package s2Member\Utilities
1789 * @since 260906.0738
1790 *
1791 * @param array $assets Active generated JavaScript assets keyed by logical filename.
1792 * @return string Inline JavaScript, or an empty string when data-map loading fails.
1793 */
1794 public static function static_js_inline_data($assets = array())
1795 {
1796 if(self::static_js_text_delivery() !== 'page')
1797 return '';
1798 $paths = array();
1799 foreach(array_keys((array)$assets) as $id)
1800 foreach(self::static_js_data_map_paths($id) as $key => $path)
1801 $paths[$key] = $path;
1802 if(!$paths)
1803 return '';
1804
1805 $data = array();
1806 foreach($paths as $key => $path)
1807 {
1808 $data_map = self::load_static_js_data_map($path);
1809 if($data_map === FALSE)
1810 return '';
1811 $data[$key] = $data_map;
1812 }
1813 $json = wp_json_encode($data);
1814 if(!is_string($json) || $json === '')
1815 return '';
1816 //260906.0738 wp_add_inline_script() prints this in HTML; neutralize user-translatable closing-script sequences just like existing inline current-user globals.
1817 $inline = 'window.s2_data='.str_ireplace('</', '<\\/', $json).';';
1818 $extra = (string)apply_filters('ws_plugin__s2member_static_js_inline_globals', '', $assets, get_defined_vars());
1819 $extra = str_ireplace('</', '<\\/', $extra); //260906.0738 Pro gateway globals may contain translated text too, so apply the same closing-script protection.
1820 return $inline.(($extra !== '') ? "\n".$extra : '');
1821 }
1822
1823 /**
1824 * Formats one preserved source notice compactly without turning it into an unreadable single line.
1825 *
1826 * @package s2Member\Utilities
1827 * @since 260905.0106
1828 *
1829 * @param string $comment Original leading source docblock.
1830 * @return string Compact readable preserved notice.
1831 */
1832 protected static function preserved_asset_header($comment = '')
1833 {
1834 $header = trim((string)$comment);
1835 $header = preg_replace('/\A\/\*\*|\*\/\z/', '', $header);
1836 $header = preg_replace('/^\s*\*\s?/m', '', $header);
1837 $header = preg_replace('/\s+/', ' ', trim($header));
1838 $header = str_replace(array('©', '©'), '(c)', $header);
1839 return "/*!\n * ".wordwrap($header, 140, "\n * ", FALSE)."\n */";
1840 }
1841
1842 /**
1843 * Prunes stale generated generations after a successful build.
1844 *
1845 * Keep current build files protected, retain up to ten older generations for cached HTML,
1846 * and remove anything older than 30 days. This bounds normal disk use without deleting the
1847 * previous timestamp immediately after a refresh.
1848 *
1849 * @package s2Member\Utilities
1850 * @since 260905.0106
1851 *
1852 * @param string $dir Current generated-asset directory.
1853 * @param string $new_path Newly generated file path that must be preserved.
1854 * @return null
1855 */
1856 protected static function prune_static_asset_generations($dir = '', $new_path = '')
1857 {
1858 $dir = rtrim((string)$dir, '/\\');
1859 if(!$dir || !is_dir($dir))
1860 return;
1861 $protected = array();
1862 $builds = get_option('ws_plugin__s2member_static_asset_builds', array());
1863 if(is_array($builds))
1864 foreach($builds as $id => $build)
1865 if(in_array($id, array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js'), TRUE) && ($build = abs((int)$build)))
1866 {
1867 $type = substr(strrchr($id, '.'), 1);
1868 $base = substr($id, 0, -strlen('.'.$type));
1869 $protected[$dir.'/'.$base.'-'.$build.'.'.$type] = TRUE;
1870 }
1871 if($new_path)
1872 $protected[(string)$new_path] = TRUE;
1873
1874 $groups = array();
1875 foreach(array('css', 'js') as $type)
1876 foreach((array)glob($dir.'/s2member*.'.$type) as $path)
1877 if(preg_match('/\/(s2member(?:-pro)?)-\d+\.(css|js)\z/', str_replace('\\', '/', $path), $match))
1878 $groups[$match[1].'.'.$match[2]][] = $path;
1879
1880 $removed = array(); //260907.2203 Collect only files actually removed so cleanup logging stays accurate.
1881
1882 foreach($groups as $paths)
1883 {
1884 usort($paths, function($a, $b) {
1885 return (int)@filemtime($b) - (int)@filemtime($a);
1886 });
1887 $stale_kept = 0;
1888 foreach($paths as $old_path)
1889 {
1890 if(isset($protected[$old_path]))
1891 continue;
1892 $stale_kept++;
1893 if((int)@filemtime($old_path) < time() - 30 * DAY_IN_SECONDS || $stale_kept > 10)
1894 if(@unlink($old_path))
1895 $removed[] = basename($old_path);
1896 }
1897 }
1898
1899 //260907.2203 Record cleanup only when stale files were actually deleted.
1900 if($removed)
1901 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'Stale Static CSS/JS files pruned', 'result' => 'success', 'removed' => $removed));
1902
1903 return;
1904 }
1905
1906 /**
1907 * Builds one timestamped CSS/JS file in the WordPress uploads tree.
1908 *
1909 * @package s2Member\Utilities
1910 * @since 260903.0525
1911 *
1912 * @param string $id Stable asset identifier.
1913 * @param int $build Timestamp used in the generated filename.
1914 * @param string $type `css` or `js`.
1915 * @param array $sources Ordered source definitions/files.
1916 * @param bool $minify Whether generated output should be minified.
1917 * @return array Build result with URL/path or error.
1918 */
1919 protected static function build_static_asset($id = '', $build = 0, $type = '', $sources = array(), $minify = FALSE)
1920 {
1921 $id = trim(preg_replace('/[^a-z0-9_\-]/i', '-', (string)$id), '-');
1922 $type = strtolower((string)$type);
1923 if(!$id || !$build || !in_array($type, array('css', 'js'), TRUE) || !$sources)
1924 return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'Invalid generated asset parameters');
1925
1926 $location = self::static_assets_location(TRUE);
1927 if(empty($location['ok']))
1928 return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => $location['error']);
1929 $filename = $id.'-'.(int)$build.'.'.$type;
1930 $path = $location['dir'].'/'.$filename;
1931 $url = $location['url'].'/'.$filename;
1932 $headers = array();
1933 $body = '';
1934 foreach($sources as $source)
1935 {
1936 $source = is_array($source) ? $source : array('file' => $source);
1937 if(array_key_exists('contents', $source))
1938 $chunk = (string)$source['contents'];
1939 else if(empty($source['file']) || !is_readable($source['file']))
1940 return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'Source file is not readable: '.((!empty($source['file'])) ? $source['file'] : '(missing path)'));
1941 else if(!empty($source['data_map']))
1942 {
1943 if(($chunk = file_get_contents($source['file'])) === FALSE)
1944 return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'Could not read source file: '.$source['file']);
1945 $transformed = self::static_js_data_source($chunk, (string)$source['data_map'], (!empty($source['data_key'])) ? (string)$source['data_key'] : '');
1946 if(empty($transformed['ok']))
1947 return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => (string)$transformed['error'].' in '.$source['file']);
1948 $chunk = $transformed['source'];
1949 }
1950 else if(!empty($source['render']))
1951 {
1952 $template_vars = (!empty($source['vars']) && is_array($source['vars'])) ? $source['vars'] : array();
1953 extract($template_vars, EXTR_SKIP);
1954 ob_start();
1955 include $source['file'];
1956 $chunk = ob_get_clean();
1957 }
1958 else if(($chunk = file_get_contents($source['file'])) === FALSE)
1959 return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'Could not read source file: '.$source['file']);
1960
1961 if(preg_match('/\A\s*(\/\*\*.*?\*\/)\s*/s', $chunk, $match))
1962 {
1963 if(!empty($source['preserve_header']))
1964 {
1965 //260905.0106 Preserve the complete notice in a compact wrapped block instead of a huge original banner or an unreadable single line.
1966 $header = self::preserved_asset_header($match[1]);
1967 if(!in_array($header, $headers, TRUE))
1968 $headers[] = $header;
1969 }
1970 $chunk = preg_replace('/\A\s*\/\*\*.*?\*\/\s*/s', '', $chunk, 1);
1971 }
1972 if(!empty($source['replacements']) && is_array($source['replacements']))
1973 $chunk = str_replace(array_keys($source['replacements']), array_values($source['replacements']), $chunk);
1974 $body .= "\n".((!empty($source['prefix'])) ? $source['prefix']."\n" : '').$chunk.((!empty($source['suffix'])) ? "\n".$source['suffix'] : '');
1975 }
1976
1977 //260906.2219 Personal/member globals are page-specific by design and must never be written into a publicly cacheable static JavaScript file.
1978 if($type === 'js' && preg_match('/\bS2MEMBER_CURRENT_USER_[A-Z0-9_]+\s*=(?!=)/', $body))
1979 return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'Page-specific member globals cannot be stored in static JavaScript');
1980
1981 try
1982 {
1983 $body = ($minify) ? (($type === 'css') ? self::compress_css($body) : self::compress_js($body)) : trim($body);
1984 }
1985 catch(Exception $e)
1986 {
1987 return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'JavaScript minification failed: '.$e->getMessage());
1988 }
1989 $marker = self::static_asset_marker_output($id, $type, $build);
1990 $output = (($headers) ? implode("\n", $headers)."\n" : '').$body."\n".$marker."\n";
1991 $tmp = $path.'.tmp-'.uniqid('', TRUE);
1992 if(file_put_contents($tmp, $output, LOCK_EX) === FALSE || (!@rename($tmp, $path) && !is_file($path)))
1993 {
1994 @unlink($tmp);
1995 return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'Could not write generated asset: '.$path);
1996 }
1997 @unlink($tmp);
1998
1999 return array('ok' => TRUE, 'url' => $url, 'path' => $path, 'error' => '');
2000 }
2001
2002 /**
2003 * Resolves the writable/public directory used for generated static frontend assets.
2004 *
2005 * @package s2Member\Utilities
2006 * @since 260903.0437
2007 *
2008 * @param bool $for_write Create/validate the directory for a write operation.
2009 * @return array Location result.
2010 */
2011 protected static function static_assets_location($for_write = FALSE)
2012 {
2013 $key = ($for_write) ? 'write' : 'read';
2014 if(isset(self::$static_assets_location_cache[$key]))
2015 return self::$static_assets_location_cache[$key];
2016 $uploads = wp_upload_dir(NULL, (bool)$for_write);
2017 if(!empty($uploads['error']) || empty($uploads['basedir']) || empty($uploads['baseurl']))
2018 return self::$static_assets_location_cache[$key] = array('ok' => FALSE, 'dir' => '', 'url' => '', 'error' => 'WordPress could not resolve a usable uploads directory'.((!empty($uploads['error'])) ? ': '.$uploads['error'] : ''));
2019
2020 $dir = untrailingslashit((string)apply_filters('ws_plugin__s2member_static_assets_dir', trailingslashit($uploads['basedir']).'s2member-assets', $uploads));
2021 $url = untrailingslashit((string)apply_filters('ws_plugin__s2member_static_assets_url', trailingslashit($uploads['baseurl']).'s2member-assets', $uploads));
2022 if(!$dir || !$url)
2023 return self::$static_assets_location_cache[$key] = array('ok' => FALSE, 'dir' => '', 'url' => '', 'error' => 'The static-assets directory or URL filter returned an empty value');
2024 if($for_write)
2025 {
2026 if(!is_dir($dir) && !wp_mkdir_p($dir))
2027 return self::$static_assets_location_cache[$key] = array('ok' => FALSE, 'dir' => $dir, 'url' => $url, 'error' => 'Could not create static-assets directory: '.$dir);
2028 if(!is_writable($dir))
2029 return self::$static_assets_location_cache[$key] = array('ok' => FALSE, 'dir' => $dir, 'url' => $url, 'error' => 'Static-assets directory is not writable: '.$dir);
2030
2031 //260905.0158 Discourage casual directory listing without adding executable PHP to the public uploads directory.
2032 $index_file = $dir.'/index.html';
2033 if(!is_file($index_file))
2034 @file_put_contents($index_file, '<!-- Silence is golden. -->'."\n", LOCK_EX);
2035 }
2036 return self::$static_assets_location_cache[$key] = array('ok' => TRUE, 'dir' => $dir, 'url' => $url, 'error' => '');
2037 }
2038
2039 /**
2040 * Handles CSS compression of hex colors.
2041 *
2042 * @package s2Member\Utilities
2043 * @since 3.5
2044 *
2045 * @param array $m Array of matches from ``preg_replace_callback()``.
2046 * @return string Shortened hex code when possible, full hex code otherwise.
2047 */
2048 public static function _compress_css_c3($m = FALSE)
2049 {
2050 if($m[2][0] === $m[2][1] && $m[2][2] === $m[2][3] && $m[2][4] === $m[2][5])
2051 return $m[1].$m[2][0].$m[2][2].$m[2][4];
2052 return $m[0];
2053 }
2054
2055 /**
2056 * JShrink 1.8.1 adaptation used for generated JavaScript minification.
2057 *
2058 * JShrink is Copyright (c) Robert Hafner and licensed under BSD-3-Clause.
2059 * See `/src/licensing/jshrink.txt` for the complete license and attribution.
2060 * @see https://github.com/tedious/JShrink
2061 *
2062 * The upstream parser is kept intentionally isolated behind `jshrink_*` names.
2063 * The only PHP 5.6 compatibility change avoids PHP 7.1+ negative string offsets
2064 * when tracking the last character.
2065 *
2066 * @package s2Member\Utilities
2067 * @since 260903.0437
2068 */
2069 protected $jshrink_input;
2070 protected $jshrink_len = 0;
2071 protected $jshrink_index = 0;
2072 protected $jshrink_a = '';
2073 protected $jshrink_b = '';
2074 protected $jshrink_c;
2075 protected $jshrink_last_char;
2076 protected $jshrink_output = '';
2077 protected $jshrink_options = array();
2078 protected $jshrink_string_delimiters = array("'" => TRUE, '"' => TRUE, '`' => TRUE);
2079 protected $jshrink_no_new_line_characters = array('(' => TRUE, '-' => TRUE, '+' => TRUE, '[' => TRUE, '#' => TRUE, '@' => TRUE);
2080 protected static $jshrink_default_options = array('flaggedComments' => TRUE);
2081 protected static $jshrink_keywords = array('delete', 'do', 'for', 'in', 'instanceof', 'return', 'typeof', 'yield');
2082 protected $jshrink_max_keyword_len = 0;
2083 protected $jshrink_locks = array();
2084
2085 protected function jshrink_minify_to_string($js, $options)
2086 {
2087 $this->jshrink_initialize($js, $options);
2088 $this->jshrink_loop();
2089 $output = $this->jshrink_output;
2090 $this->jshrink_clean();
2091 return $output;
2092 }
2093
2094 protected function jshrink_initialize($js, $options)
2095 {
2096 $this->jshrink_options = array_merge(self::$jshrink_default_options, $options);
2097 $this->jshrink_input = $js.PHP_EOL;
2098 $this->jshrink_len = strlen($this->jshrink_input);
2099 $this->jshrink_a = "\n";
2100 $this->jshrink_b = "\n";
2101 $this->jshrink_last_char = "\n";
2102 $this->jshrink_output = '';
2103 $this->jshrink_max_keyword_len = max(array_map('strlen', self::$jshrink_keywords));
2104 }
2105
2106 protected function jshrink_echo($char)
2107 {
2108 $this->jshrink_output .= $char;
2109 //260903.0437 JShrink 1.8.1 uses `$char[-1]`; `substr()` preserves that behavior on s2Member's PHP 5.6 minimum.
2110 $this->jshrink_last_char = substr($char, -1);
2111 }
2112
2113 protected function jshrink_loop()
2114 {
2115 while($this->jshrink_a !== FALSE && !is_null($this->jshrink_a) && $this->jshrink_a !== '')
2116 {
2117 switch($this->jshrink_a)
2118 {
2119 case "\r":
2120 case "\n":
2121 if($this->jshrink_b !== FALSE && isset($this->jshrink_no_new_line_characters[$this->jshrink_b]))
2122 {
2123 $this->jshrink_echo($this->jshrink_a);
2124 $this->jshrink_save_string();
2125 break;
2126 }
2127 if($this->jshrink_b === ' ')
2128 break;
2129 case ' ':
2130 if(self::jshrink_is_alphanumeric($this->jshrink_b))
2131 $this->jshrink_echo($this->jshrink_a);
2132 $this->jshrink_save_string();
2133 break;
2134 default:
2135 switch($this->jshrink_b)
2136 {
2137 case "\r":
2138 case "\n":
2139 if(strpos('}])+-"\'', $this->jshrink_a) !== FALSE)
2140 {
2141 $this->jshrink_echo($this->jshrink_a);
2142 $this->jshrink_save_string();
2143 break;
2144 }
2145 else if(self::jshrink_is_alphanumeric($this->jshrink_a))
2146 {
2147 $this->jshrink_echo($this->jshrink_a);
2148 $this->jshrink_save_string();
2149 }
2150 break;
2151 case ' ':
2152 if(!self::jshrink_is_alphanumeric($this->jshrink_a))
2153 break;
2154 default:
2155 if($this->jshrink_a === '/' && ($this->jshrink_b === "'" || $this->jshrink_b === '"'))
2156 {
2157 $this->jshrink_save_regex();
2158 continue 3;
2159 }
2160 $this->jshrink_echo($this->jshrink_a);
2161 $this->jshrink_save_string();
2162 break;
2163 }
2164 }
2165
2166 $this->jshrink_b = $this->jshrink_get_real();
2167 if($this->jshrink_b == '/')
2168 {
2169 $valid_tokens = "(,=:[!&|?\n";
2170 $last_token = ($this->jshrink_a == ' ') ? $this->jshrink_last_char : $this->jshrink_a;
2171 if(strpos($valid_tokens, $last_token) !== FALSE || $this->jshrink_ends_in_keyword())
2172 $this->jshrink_save_regex();
2173 }
2174 }
2175 }
2176
2177 protected function jshrink_clean()
2178 {
2179 unset($this->jshrink_input, $this->jshrink_c, $this->jshrink_options);
2180 $this->jshrink_len = $this->jshrink_index = 0;
2181 $this->jshrink_a = $this->jshrink_b = '';
2182 $this->jshrink_output = '';
2183 }
2184
2185 protected function jshrink_get_char()
2186 {
2187 if(isset($this->jshrink_c))
2188 {
2189 $char = $this->jshrink_c;
2190 unset($this->jshrink_c);
2191 }
2192 else
2193 {
2194 $char = ($this->jshrink_index < $this->jshrink_len) ? $this->jshrink_input[$this->jshrink_index] : FALSE;
2195 if($char === FALSE)
2196 return FALSE;
2197 $this->jshrink_index++;
2198 }
2199 if($char == "\r")
2200 $char = "\n";
2201 if($char !== "\n" && $char < "\x20")
2202 return ' ';
2203 return $char;
2204 }
2205
2206 protected function jshrink_peek()
2207 {
2208 if($this->jshrink_index >= $this->jshrink_len)
2209 return FALSE;
2210 $char = $this->jshrink_input[$this->jshrink_index];
2211 if($char == "\r")
2212 $char = "\n";
2213 if($char !== "\n" && $char < "\x20")
2214 return ' ';
2215 return $char;
2216 }
2217
2218 protected function jshrink_get_real()
2219 {
2220 $start_index = $this->jshrink_index;
2221 $char = $this->jshrink_get_char();
2222 if($char !== '/')
2223 return $char;
2224 $this->jshrink_c = $this->jshrink_get_char();
2225 if($this->jshrink_c === '/')
2226 {
2227 $this->jshrink_process_one_line_comments($start_index);
2228 return $this->jshrink_get_real();
2229 }
2230 else if($this->jshrink_c === '*')
2231 {
2232 $this->jshrink_process_multi_line_comments($start_index);
2233 return $this->jshrink_get_real();
2234 }
2235 return $char;
2236 }
2237
2238 protected function jshrink_process_one_line_comments($start_index)
2239 {
2240 $third = ($this->jshrink_index < $this->jshrink_len) ? $this->jshrink_input[$this->jshrink_index] : FALSE;
2241 $this->jshrink_get_next("\n");
2242 unset($this->jshrink_c);
2243 if($third == '@')
2244 {
2245 $end = $this->jshrink_index - $start_index;
2246 $this->jshrink_c = "\n".substr($this->jshrink_input, $start_index, $end);
2247 }
2248 }
2249
2250 protected function jshrink_process_multi_line_comments($start_index)
2251 {
2252 $this->jshrink_get_char();
2253 $third = $this->jshrink_get_char();
2254 if($third == '*' && $this->jshrink_peek() == '/')
2255 {
2256 $this->jshrink_index++;
2257 return;
2258 }
2259 if($this->jshrink_get_next('*/'))
2260 {
2261 $this->jshrink_get_char();
2262 $this->jshrink_get_char();
2263 $char = $this->jshrink_get_char();
2264 if((!empty($this->jshrink_options['flaggedComments']) && $third === '!') || $third === '@')
2265 {
2266 if($start_index > 0)
2267 {
2268 $this->jshrink_echo($this->jshrink_a);
2269 $this->jshrink_a = ' ';
2270 if($this->jshrink_input[$start_index - 1] === "\n")
2271 $this->jshrink_echo("\n");
2272 }
2273 $end = ($this->jshrink_index - 1) - $start_index;
2274 $this->jshrink_echo(substr($this->jshrink_input, $start_index, $end));
2275 $this->jshrink_c = $char;
2276 return;
2277 }
2278 }
2279 else
2280 $char = FALSE;
2281 if($char === FALSE)
2282 throw new RuntimeException('Unclosed multiline comment at position: '.($this->jshrink_index - 2));
2283 $this->jshrink_c = $char;
2284 }
2285
2286 protected function jshrink_get_next($string)
2287 {
2288 $pos = strpos($this->jshrink_input, $string, $this->jshrink_index);
2289 if($pos === FALSE)
2290 return FALSE;
2291 $this->jshrink_index = $pos;
2292 return ($this->jshrink_index < $this->jshrink_len) ? $this->jshrink_input[$this->jshrink_index] : FALSE;
2293 }
2294
2295 protected function jshrink_save_string()
2296 {
2297 $start = $this->jshrink_index;
2298 $this->jshrink_a = $this->jshrink_b;
2299 if(!isset($this->jshrink_string_delimiters[$this->jshrink_a]))
2300 return;
2301 $type = $this->jshrink_a;
2302 $this->jshrink_echo($this->jshrink_a);
2303 while(($this->jshrink_a = $this->jshrink_get_char()) !== FALSE)
2304 {
2305 switch($this->jshrink_a)
2306 {
2307 case $type:
2308 break 2;
2309 case "\n":
2310 if($type === '`')
2311 $this->jshrink_echo($this->jshrink_a);
2312 else
2313 throw new RuntimeException('Unclosed string at position: '.$start);
2314 break;
2315 case '\\':
2316 $this->jshrink_b = $this->jshrink_get_char();
2317 if($this->jshrink_b !== "\n")
2318 $this->jshrink_echo($this->jshrink_a.$this->jshrink_b);
2319 break;
2320 default:
2321 $this->jshrink_echo($this->jshrink_a);
2322 }
2323 }
2324 }
2325
2326 protected function jshrink_save_regex()
2327 {
2328 if($this->jshrink_a != ' ')
2329 $this->jshrink_echo($this->jshrink_a);
2330 $this->jshrink_echo($this->jshrink_b);
2331 $character_class = FALSE;
2332 $character_class_index = NULL;
2333 while(($this->jshrink_a = $this->jshrink_get_char()) !== FALSE)
2334 {
2335 if($this->jshrink_a === '/' && !$character_class)
2336 break;
2337 if($this->jshrink_a === '[')
2338 {
2339 $character_class = TRUE;
2340 $character_class_index = $this->jshrink_index;
2341 }
2342 else if($this->jshrink_a === ']')
2343 $character_class = FALSE;
2344 if($this->jshrink_a === '\\')
2345 {
2346 $this->jshrink_echo($this->jshrink_a);
2347 $this->jshrink_a = $this->jshrink_get_char();
2348 }
2349 if($this->jshrink_a === "\n")
2350 {
2351 if($character_class)
2352 throw new RuntimeException('Unclosed character class at position: '.$character_class_index);
2353 throw new RuntimeException('Unclosed regex pattern at position: '.$this->jshrink_index);
2354 }
2355 $this->jshrink_echo($this->jshrink_a);
2356 }
2357 $this->jshrink_b = $this->jshrink_get_real();
2358 }
2359
2360 protected static function jshrink_is_alphanumeric($char)
2361 {
2362 return preg_match('/^[\w\$\pL]$/', $char) === 1 || $char == '/';
2363 }
2364
2365 protected function jshrink_ends_in_keyword()
2366 {
2367 $test = substr($this->jshrink_output.$this->jshrink_a, -1 * ($this->jshrink_max_keyword_len + 10));
2368 foreach(self::$jshrink_keywords as $keyword)
2369 if(preg_match('/[^\w]'.$keyword.'[ ]?$/i', $test) === 1)
2370 return TRUE;
2371 return FALSE;
2372 }
2373
2374 protected function jshrink_lock($js)
2375 {
2376 $lock = '"LOCK---'.crc32(time()).'"';
2377 $matches = array();
2378 preg_match('/([+-])(\s+)([+-])/S', $js, $matches);
2379 if(empty($matches))
2380 return $js;
2381 $this->jshrink_locks[$lock] = $matches[2];
2382 return preg_replace('/([+-])\s+([+-])/S', '$1'.$lock.'$2', $js);
2383 }
2384
2385 protected function jshrink_unlock($js)
2386 {
2387 foreach($this->jshrink_locks as $lock => $replacement)
2388 $js = str_replace($lock, $replacement, $js);
2389 return $js;
2390 }
2391 }
2392 }
2393