PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 260917
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v260917
260917 260913 260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 111216 111220 120213 All 189 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 260917, at src/includes/classes/utils-assets.inc.php

4,448 lines 213.8 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 $static_assets_rebuild_after_save = array(); //260911.1834 Relevant saved CSS/JS option changes queue enabled static types for an immediate post-save rebuild.
36 protected static $asset_http_health_cache;
37 protected static $page_asset_expectations = array();
38 protected static $asset_health_force_full_probe = FALSE; //260910.0630 The Health panel can request a fresh trusted current-delivery probe without changing saved delivery settings.
39
40 /**
41 * Handles CSS compression.
42 *
43 * @package s2Member\Utilities
44 * @since 3.5
45 *
46 * @param string $css A string of CSS.
47 * @return string String of CSS, after compression.
48 */
49 public static function compress_css($css = FALSE)
50 {
51 $c6 = "/(\:#| #)([A-Z0-9]{6})/i";
52 $css = preg_replace("/\/\*(.*?)\*\//s", "", $css);
53 $css = preg_replace("/[\r\n\t]+/", "", $css);
54 $css = preg_replace("/ {2,}/", " ", $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 $css = preg_replace("/ ; | ;|; /", ";", $css);
72 $css = preg_replace("/;\}/", "}", $css);
73
74 return preg_replace_callback($c6, 'c_ws_plugin__s2member_utils_assets::_compress_css_c3', $css);
75 }
76
77 /**
78 * Compresses JavaScript using an s2Member-adapted implementation of JShrink 1.8.1.
79 *
80 * JShrink is Copyright (c) Robert Hafner and licensed under BSD-3-Clause.
81 * See `/src/licensing/jshrink.txt` for the complete license and attribution.
82 * @see https://github.com/tedious/JShrink
83 *
84 * @package s2Member\Utilities
85 * @since 260903.0437
86 *
87 * @param string $js JavaScript source.
88 * @return string Minified JavaScript.
89 * @throws RuntimeException If malformed JavaScript cannot be minified safely.
90 */
91 public static function compress_js($js = '')
92 {
93 $minifier = new self();
94 try
95 {
96 $js = $minifier->jshrink_lock((string)$js);
97 $js = ltrim($minifier->jshrink_minify_to_string($js, array('flaggedComments' => TRUE)));
98 $js = $minifier->jshrink_unlock($js);
99 $minifier->jshrink_clean();
100 return $js;
101 }
102 catch(Exception $e)
103 {
104 $minifier->jshrink_clean();
105 throw $e;
106 }
107 }
108
109 /**
110 * Returns the selected URL used whenever frontend CSS/JavaScript needs dynamic generation.
111 *
112 * 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.
113 * In the current UI this established route is named the s2Member-Only Dynamic Loader and is served by s2member-o.php.
114 *
115 * @package s2Member\Utilities
116 * @since 260904.0221
117 *
118 * @param bool $force_wordpress Force the full WordPress route for a compatibility fallback.
119 * @return string Dynamic frontend asset URL without query arguments.
120 */
121 public static function dynamic_asset_url($force_wordpress = FALSE)
122 {
123 if(!$force_wordpress && (empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress')
124 && is_file(self::s2o_file_path()) && !self::asset_http_target_failed('s2o', $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url']))
125 return $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'];
126
127 return self::wordpress_dynamic_asset_url();
128 }
129
130 /**
131 * Returns the normal WordPress front-controller URL for dynamic frontend assets.
132 *
133 * @package s2Member\Utilities
134 * @since 260904.2110
135 *
136 * @return string WordPress dynamic frontend asset URL without query arguments.
137 */
138 protected static function wordpress_dynamic_asset_url()
139 {
140 global $wp_rewrite;
141
142 $index = (is_object($wp_rewrite) && !empty($wp_rewrite->index)) ? ltrim((string)$wp_rewrite->index, '/') : '';
143 if($index === '')
144 $index = 'index.php';
145 return home_url('/'.$index);
146 }
147
148 /**
149 * Returns the local s2member-o.php path.
150 *
151 * @package s2Member\Utilities
152 * @since 260904.2110
153 *
154 * @return string Local filesystem path.
155 */
156 protected static function s2o_file_path()
157 {
158 return $GLOBALS['WS_PLUGIN__']['s2member']['c']['dir'].'/'.preg_replace('/\.php$/', '-o.php', basename($GLOBALS['WS_PLUGIN__']['s2member']['l']));
159 }
160
161 /**
162 * Returns true when a trusted browser probe has confirmed that one current asset URL is unreachable.
163 *
164 * @package s2Member\Utilities
165 * @since 260904.2110
166 *
167 * @param string $id Logical health target ID.
168 * @param string $url Current public asset URL.
169 * @return bool True when the exact current URL has a recorded failure.
170 */
171 protected static function asset_http_target_failed($id = '', $url = '')
172 {
173 $health = self::asset_http_health_state();
174 return !empty($health['failures'][$id]['url']) && (string)$health['failures'][$id]['url'] === (string)$url;
175 }
176
177 /**
178 * Returns the browser-reported frontend asset health state once per PHP request.
179 *
180 * @package s2Member\Utilities
181 * @since 260904.2110
182 *
183 * @return array Stored HTTP health state.
184 */
185 protected static function asset_http_health_state()
186 {
187 if(!isset(self::$asset_http_health_cache))
188 {
189 $health = get_option('ws_plugin__s2member_asset_http_health', array());
190 self::$asset_http_health_cache = (is_array($health)) ? $health : array();
191 }
192 return self::$asset_http_health_cache;
193 }
194
195 /**
196 * Returns true while a trusted Full WordPress fallback failure is still active.
197 *
198 * @package s2Member\Utilities
199 * @since 260912.1959
200 *
201 * @return bool True when CSS or JS fallback health is currently failed.
202 */
203 protected static function asset_health_fallback_problem_active()
204 {
205 $health = self::asset_http_health_state();
206 $failures = (is_array($health) && !empty($health['failures']) && is_array($health['failures'])) ? $health['failures'] : array();
207 return !empty($health['fallback_problem_since']) && (!empty($failures['fallback:dynamic_css']) || !empty($failures['fallback:dynamic_js']));
208 }
209
210 /**
211 * Adds or updates compact recent per-asset issue details for the Health panel.
212 *
213 * This diagnostic summary is intentionally kept with the trusted HTTP-health state instead of
214 * the frontend load log. It remains available when css-js.log is disabled, and one keyed entry
215 * per affected physical target prevents the state from growing with traffic.
216 *
217 * @package s2Member\Utilities
218 * @since 260910.2346
219 *
220 * @param array $issues Existing recent issue map.
221 * @param string $key Stable physical-target key.
222 * @param string $result Issue result (`late` or `failed`).
223 * @param string $label Human-readable physical asset/route label.
224 * @param string $detail Concise failure/timing detail.
225 * @param string $url Relevant asset URL.
226 * @param string $delivery Delivery mode when known.
227 * @return array Updated issue map.
228 */
229 protected static function add_asset_health_recent_issue($issues = array(), $key = '', $result = '', $label = '', $detail = '', $url = '', $delivery = '')
230 {
231 $issues = (is_array($issues)) ? $issues : array();
232 $key = substr(sanitize_key((string)$key), 0, 120);
233 $result = strtolower((string)$result);
234 if($key === '' || !in_array($result, array('late', 'failed'), TRUE))
235 return $issues;
236
237 $previous = (!empty($issues[$key]) && is_array($issues[$key])) ? $issues[$key] : array();
238 $issues[$key] = array(
239 'label' => substr(sanitize_text_field((string)$label), 0, 120),
240 'result' => $result,
241 'delivery' => substr(sanitize_text_field((string)$delivery), 0, 80),
242 'detail' => substr(sanitize_text_field((string)$detail), 0, 200),
243 'url' => esc_url_raw((string)$url),
244 'first_seen' => (!empty($previous['first_seen'])) ? (int)$previous['first_seen'] : time(),
245 'last_seen' => time(),
246 'count' => (!empty($previous['count'])) ? (int)$previous['count'] + 1 : 1,
247 );
248 return $issues;
249 }
250
251
252 /**
253 * Returns the compact rolling CSS/JavaScript asset-load health log.
254 *
255 * The state keeps only the latest 10 individual asset loads, populated clock-minute
256 * aggregates from the latest 10 minutes, and populated clock-aligned 10-minute aggregates
257 * from the latest 6 hours since Health last became non-Green. Minute/block keys are their
258 * clock-aligned ending timestamps. Buckets retain sum/count so an accepted Late report can
259 * correct the earlier Okay rating exactly, even after that clock period ended. Empty periods
260 * are never manufactured as health evidence.
261 *
262 * @package s2Member\Utilities
263 * @since 260910.0630
264 *
265 * @return array Stored rolling asset-load health state.
266 */
267 protected static function asset_health_log_state()
268 {
269 //260910.2346 Keep the hot-path state small and self-describing; every retained collection has a fixed request/time horizon and the option does not autoload.
270 $state = get_option('ws_plugin__s2member_assets_health_log', array());
271 $state = (is_array($state)) ? $state : array();
272 $state['last_10_asset_loads'] = (!empty($state['last_10_asset_loads']) && is_array($state['last_10_asset_loads'])) ? array_values($state['last_10_asset_loads']) : array();
273 $state['last_10min_minutes'] = (!empty($state['last_10min_minutes']) && is_array($state['last_10min_minutes'])) ? $state['last_10min_minutes'] : array();
274 $state['last_6hour_10min_blocks'] = (!empty($state['last_6hour_10min_blocks']) && is_array($state['last_6hour_10min_blocks'])) ? $state['last_6hour_10min_blocks'] : array();
275 //260911.1806 Keep one compact historical issue outside the rolling score windows so Last issue remains useful after busy healthy traffic or natural recovery.
276 $state['last_issue'] = (!empty($state['last_issue']) && is_array($state['last_issue'])) ? $state['last_issue'] : array();
277 //260913.0041 Keep a bounded always-available troubleshooting summary independent of the rolling score and optional css-js.log.
278 $state['latest_issues'] = (!empty($state['latest_issues']) && is_array($state['latest_issues'])) ? array_slice($state['latest_issues'], 0, 10, TRUE) : array();
279 $state['last_issue_cleared_at'] = (!empty($state['last_issue_cleared_at'])) ? (int)$state['last_issue_cleared_at'] : 0;
280 $state['latest_issues_cleared_at'] = (!empty($state['latest_issues_cleared_at'])) ? (int)$state['latest_issues_cleared_at'] : 0;
281 //260912.0258 Keep a small duplicate-processing safeguard inside the existing health log in case a queued event survives after its changes were already stored.
282 $state['processed_event_times'] = (!empty($state['processed_event_times']) && is_array($state['processed_event_times'])) ? array_slice(array_values($state['processed_event_times']), -100) : array();
283 return $state;
284 }
285
286 /**
287 * Returns the option-name prefix used by queued asset-health events.
288 *
289 * Frontend requests queue separate non-autoloaded events instead of rewriting the shared rolling
290 * log. The Health Logkeeper later merges those events into the one persistent health-log option.
291 *
292 * @package s2Member\Utilities
293 * @since 260912.0258
294 *
295 * @return string Event option prefix.
296 */
297 protected static function asset_health_event_option_prefix()
298 {
299 return 'ws_plugin__s2member_assets_health_event_';
300 }
301
302 /**
303 * Queues one asset-health event without waiting for or rewriting the shared rolling log.
304 *
305 * The option suffix combines the event time with the queue time in microseconds. That meaningful
306 * pair gives chronological ordering, practical uniqueness, and duplicate-processing identity.
307 *
308 * @package s2Member\Utilities
309 * @since 260912.0258
310 *
311 * @param array $event Compact load/issue event.
312 * @return string Queued event-times suffix, or an empty string on failure.
313 */
314 protected static function queue_asset_health_event($event = array())
315 {
316 $event = (is_array($event)) ? $event : array();
317 $event_time = (!empty($event['event_time'])) ? max(1, (int)$event['event_time']) : time();
318 $microtime = explode(' ', microtime(), 2);
319 $queued_sec = (!empty($microtime[1])) ? max(1, (int)$microtime[1]) : time();
320 $queued_usec = (!empty($microtime[0])) ? (int)substr($microtime[0], 2, 6) : 0;
321 $prefix = self::asset_health_event_option_prefix();
322 $event_time_order = str_pad((string)$event_time, 12, '0', STR_PAD_LEFT);
323
324 //260912.0258 add_option() provides the atomic uniqueness check; an extraordinarily unlikely collision simply advances the queue time by one microsecond and retries.
325 for($attempt = 0; $attempt < 3; $attempt++)
326 {
327 $queued_time_order = str_pad((string)$queued_sec, 12, '0', STR_PAD_LEFT).str_pad((string)$queued_usec, 6, '0', STR_PAD_LEFT);
328 $event_times = $event_time_order.'_'.$queued_time_order;
329 if(add_option($prefix.$event_times, $event, '', 'no'))
330 {
331 //260912.0258 Schedule the Health Logkeeper without making the visitor wait for health-log maintenance.
332 if(!wp_next_scheduled('ws_plugin__s2member_assets_health_logkeeper'))
333 wp_schedule_single_event(time() + 10, 'ws_plugin__s2member_assets_health_logkeeper');
334 return $event_times;
335 }
336 if(++$queued_usec > 999999)
337 {
338 $queued_usec = 0;
339 $queued_sec++;
340 }
341 }
342 return '';
343 }
344
345 /**
346 * Acquires the Health Logkeeper lock without waiting.
347 *
348 * Only the Health Logkeeper writes the shared rolling health log. If another Logkeeper run is
349 * active, this request exits immediately; frontend requests only queue events and never wait here.
350 *
351 * @package s2Member\Utilities
352 * @since 260912.0258
353 *
354 * @return string Unique lock value, or an empty string when another Logkeeper run owns it.
355 */
356 protected static function health_logkeeper_lock_acquire()
357 {
358 global $wpdb;
359
360 $option = 'ws_plugin__s2member_assets_health_logkeeper_lock';
361 $lock = time().':'.sha1(microtime(TRUE)."\0".wp_rand());
362 if(add_option($option, $lock, '', 'no'))
363 return $lock;
364
365 $current = (string)get_option($option, '');
366 $parts = explode(':', $current, 2);
367 $locked_at = (!empty($parts[0]) && is_numeric($parts[0])) ? (int)$parts[0] : 0;
368 //260913.0454 Delete only the stale lock version we inspected; another Logkeeper may replace it before this request reaches the delete.
369 if(!$locked_at || $locked_at < time() - 2 * MINUTE_IN_SECONDS)
370 {
371 $deleted = $wpdb->delete($wpdb->options, array('option_name' => $option, 'option_value' => maybe_serialize($current)), array('%s', '%s'));
372 if($deleted)
373 {
374 wp_cache_delete($option, 'options');
375 if(add_option($option, $lock, '', 'no'))
376 return $lock;
377 }
378 }
379 return '';
380 }
381
382 /**
383 * Releases the Health Logkeeper lock when this request still owns it.
384 *
385 * @package s2Member\Utilities
386 * @since 260912.0258
387 *
388 * @param string $lock Unique lock value returned by health_logkeeper_lock_acquire().
389 * @return null
390 */
391 protected static function health_logkeeper_lock_release($lock = '')
392 {
393 global $wpdb;
394
395 $option = 'ws_plugin__s2member_assets_health_logkeeper_lock';
396 $current = (string)get_option($option, '');
397 if($lock !== '' && $current !== '' && hash_equals($current, (string)$lock))
398 {
399 //260913.0454 Release only the exact lock version owned by this request; an expired owner must never delete a newer Logkeeper's lock.
400 $deleted = $wpdb->delete($wpdb->options, array('option_name' => $option, 'option_value' => maybe_serialize($current)), array('%s', '%s'));
401 if($deleted)
402 wp_cache_delete($option, 'options');
403 }
404 return;
405 }
406
407 /**
408 * Replaces Last issue only when the candidate issue is at least as recent as the current one.
409 *
410 * @package s2Member\Utilities
411 * @since 260911.2325
412 *
413 * @param array $state Asset-health log state, passed by reference.
414 * @param int $time Issue timestamp.
415 * @param string $result Compact historical result key.
416 * @param string $label Site-owner-friendly asset label.
417 * @param string $detail Concise explanation.
418 * @param string $load_id Optional related load ID.
419 * @param int $page_id Optional WordPress post/page ID.
420 * @param string $page_path Optional queryless frontend path.
421 * @return bool True when Last issue was replaced.
422 */
423 protected static function set_asset_health_last_issue(&$state, $time = 0, $result = '', $label = '', $detail = '', $load_id = '', $page_id = 0, $page_path = '')
424 {
425 $time = max(1, (int)$time);
426 $result = strtolower((string)$result);
427 $current_time = (!empty($state['last_issue']['time'])) ? (int)$state['last_issue']['time'] : 0;
428 $cleared_at = (!empty($state['last_issue_cleared_at'])) ? (int)$state['last_issue_cleared_at'] : 0;
429 if($result === '' || $current_time > $time || $cleared_at >= $time)
430 return FALSE;
431
432 //260911.2325 Delayed browser reports may arrive out of order; Last issue must follow event time, not whichever request happened to write last.
433 $state['last_issue'] = array(
434 'time' => $time,
435 'result' => $result,
436 'label' => substr((string)$label, 0, 100),
437 'detail' => substr(wp_strip_all_tags((string)$detail), 0, 240),
438 'load_id' => (string)$load_id,
439 'page_id' => max(0, (int)$page_id),
440 'page_path' => substr((string)$page_path, 0, 240),
441 );
442 return TRUE;
443 }
444
445 /**
446 * Adds one occurrence to the bounded persistent Asset Health Latest Issues summary.
447 *
448 * Distinct issues are grouped by asset/result/page so repeats do not crowd out other problems.
449 * Each group keeps a total count and up to 10 recent occurrence times.
450 *
451 * @package s2Member\Utilities
452 * @since 260913.0041
453 *
454 * @param array $state Asset-health log state, passed by reference.
455 * @param int $time Original issue timestamp.
456 * @param string $result Compact issue result.
457 * @param array $issue Issue context.
458 * @param array $page Page context with `page_id` and `page_path`.
459 * @return bool True when the summary changed.
460 */
461 protected static function add_asset_health_latest_issue(&$state, $time = 0, $result = '', $issue = array(), $page = array())
462 {
463 $time = max(1, (int)$time);
464 $result = strtolower((string)$result);
465 $issue = (is_array($issue)) ? $issue : array();
466 $page = (is_array($page)) ? $page : array();
467 $cleared_at = (!empty($state['latest_issues_cleared_at'])) ? (int)$state['latest_issues_cleared_at'] : 0;
468 if($result === '' || $cleared_at >= $time)
469 return FALSE;
470
471 $asset = substr(sanitize_text_field((!empty($issue['asset'])) ? (string)$issue['asset'] : ''), 0, 120);
472 $label = substr(sanitize_text_field((!empty($issue['label'])) ? (string)$issue['label'] : ''), 0, 120);
473 $detail = substr(wp_strip_all_tags((!empty($issue['detail'])) ? (string)$issue['detail'] : ''), 0, 240);
474 $delivery = substr(sanitize_text_field((!empty($issue['delivery'])) ? (string)$issue['delivery'] : ''), 0, 80);
475 $page_id = (!empty($page['page_id'])) ? max(0, (int)$page['page_id']) : 0;
476 $page_path = (!empty($page['page_path'])) ? substr((string)$page['page_path'], 0, 240) : '';
477 if($label === '' && $detail === '' && $asset === '')
478 return FALSE;
479
480 $page_identity = ($page_id > 0) ? 'id:'.$page_id : 'path:'.$page_path;
481 $key = sha1(($asset !== '' ? $asset : $label)."\0".$result."\0".$page_identity);
482 $issues = (!empty($state['latest_issues']) && is_array($state['latest_issues'])) ? $state['latest_issues'] : array();
483 $previous = (!empty($issues[$key]) && is_array($issues[$key])) ? $issues[$key] : array();
484 $times = (!empty($previous['times']) && is_array($previous['times'])) ? array_values($previous['times']) : array();
485 array_unshift($times, $time);
486 rsort($times, SORT_NUMERIC); //260913.0041 Delayed reports can arrive after newer issues; retain the 10 most recent occurrence times by event time, not processing order.
487 $times = array_slice($times, 0, 10);
488 $issues[$key] = array(
489 'asset' => $asset,
490 'label' => $label,
491 'result' => $result,
492 'delivery' => $delivery,
493 'detail' => $detail,
494 'page_id' => $page_id,
495 'page_path' => $page_path,
496 'first_seen' => (!empty($previous['first_seen'])) ? min((int)$previous['first_seen'], $time) : $time,
497 'last_seen' => (!empty($previous['last_seen'])) ? max((int)$previous['last_seen'], $time) : $time,
498 'count' => (!empty($previous['count'])) ? (int)$previous['count'] + 1 : 1,
499 'times' => $times,
500 );
501 uasort($issues, function($a, $b) {
502 $a_time = (!empty($a['last_seen'])) ? (int)$a['last_seen'] : 0;
503 $b_time = (!empty($b['last_seen'])) ? (int)$b['last_seen'] : 0;
504 return ($a_time === $b_time) ? 0 : (($a_time > $b_time) ? -1 : 1);
505 });
506 $state['latest_issues'] = array_slice($issues, 0, 10, TRUE);
507 return TRUE;
508 }
509
510 /**
511 * Returns the numeric rating for one asset-load result.
512 *
513 * @package s2Member\Utilities
514 * @since 260910.0630
515 *
516 * @param string $result Asset-load result: `okay`, `late`, `fallback`, or `failed`.
517 * @return int Rating from 1 through 4, or zero when invalid.
518 */
519 protected static function asset_health_load_rating($result = '')
520 {
521 $ratings = array('okay' => 4, 'late' => 3, 'fallback' => 2, 'failed' => 1); //260910.2346 Persist full result words so the health log remains readable without an O/L/F/X legend; the numeric value is used only for scoring.
522 $result = strtolower((string)$result);
523 return isset($ratings[$result]) ? $ratings[$result] : 0;
524 }
525
526 /**
527 * Returns the ending timestamp of the clock-aligned period containing a timestamp.
528 *
529 * A timestamp exactly on a boundary belongs to the period ending at that boundary. Thus a
530 * 10-minute period ending 12:10:00 represents 12:00:01 through 12:10:00 at whole-second precision.
531 *
532 * @package s2Member\Utilities
533 * @since 260910.2346
534 *
535 * @param int $time Timestamp.
536 * @param int $seconds Period size in seconds.
537 * @return int Clock-aligned period ending timestamp.
538 */
539 protected static function asset_health_period_end($time = 0, $seconds = 0)
540 {
541 $time = max(1, (int)$time);
542 $seconds = max(1, (int)$seconds);
543 return (int)(ceil($time / $seconds) * $seconds);
544 }
545
546 /**
547 * Converts the final 1.00-4.00 health score to the site-owner status color.
548 *
549 * @package s2Member\Utilities
550 * @since 260910.0630
551 *
552 * @param float|null $score Final health score, or NULL when there is no evidence yet.
553 * @param string $latest_result Retained for call-site compatibility; the weighted score now determines status by itself.
554 * @return string Status-light key.
555 */
556 protected static function asset_health_status_from_score($score = NULL, $latest_result = '')
557 {
558 if($score === NULL)
559 return 'unknown';
560 $score = (float)$score;
561 //260913.0046 Let the weighted score decide Health consistently; recency already gives a new non-Okay result the strongest influence without an extra status override.
562 //260912.2005 Exact half-point boundaries belong to the less-healthy band; use the documented two-decimal cutoffs so 2.50, for example, is Working, review suggested rather than Recent issue.
563 if($score >= 3.51)
564 return 'healthy';
565 if($score >= 2.51)
566 return 'delayed';
567 if($score >= 1.51)
568 return 'attention';
569 return 'error';
570 }
571
572 /**
573 * Recalculates request, time, and final health scores from the retained asset-load log.
574 *
575 * Newer asset loads have importance 10 down through 1. Each populated clock minute first
576 * averages all asset-load ratings inside it, then receives importance 10 for the current minute
577 * down through 1 nine minutes ago. Empty minutes are skipped instead of inventing evidence.
578 *
579 * @package s2Member\Utilities
580 * @since 260910.0630
581 *
582 * @param array|null $state Optional already-loaded asset health log.
583 * @return array Request/time/final scores and supporting counts.
584 */
585 protected static function asset_health_scores($state = NULL)
586 {
587 $state = (is_array($state)) ? $state : self::asset_health_log_state();
588 $loads = (!empty($state['last_10_asset_loads']) && is_array($state['last_10_asset_loads'])) ? array_values($state['last_10_asset_loads']) : array();
589 $loads = array_slice($loads, -10);
590 $request_total = 0.0;
591 $request_importance = 0;
592 $importance = 10;
593 //260910.0709 Newest asset load matters most (10) and the oldest retained load least (1); divide by total importance below so the result stays on the same 1.00-4.00 scale.
594 for($i = count($loads) - 1; $i >= 0 && $importance >= 1; $i--, $importance--)
595 {
596 $rating = (!empty($loads[$i]['result'])) ? self::asset_health_load_rating($loads[$i]['result']) : 0;
597 if(!$rating)
598 continue;
599 $request_total += $rating * $importance;
600 $request_importance += $importance;
601 }
602 $request_score = ($request_importance) ? $request_total / $request_importance : NULL;
603
604 $current_minute_end = self::asset_health_period_end(time(), MINUTE_IN_SECONDS);
605 $time_total = 0.0;
606 $time_importance = 0;
607 $time_count = 0;
608 //260910.0709 Time Health averages every load inside a populated clock minute before applying recency importance, so heavy traffic cannot dominate other minutes and mixed outcomes inside one minute are not discarded.
609 foreach((!empty($state['last_10min_minutes']) && is_array($state['last_10min_minutes'])) ? $state['last_10min_minutes'] : array() as $minute_end => $bucket)
610 {
611 $minute_end = (int)$minute_end;
612 $age = (int)(($current_minute_end - $minute_end) / MINUTE_IN_SECONDS);
613 $bucket_count = (!empty($bucket['count'])) ? (int)$bucket['count'] : 0;
614 $bucket_sum = (isset($bucket['sum'])) ? (float)$bucket['sum'] : 0.0;
615 if($age < 0 || $age > 9 || $bucket_count < 1)
616 continue;
617 $rating = $bucket_sum / $bucket_count;
618 if($rating < 1 || $rating > 4)
619 continue;
620 $importance = 10 - $age;
621 $time_total += $rating * $importance;
622 $time_importance += $importance;
623 $time_count++;
624 }
625 $time_score = ($time_importance) ? $time_total / $time_importance : NULL;
626 //260910.0709 Request history and clock-time history get equal final influence when both exist; neither perspective can silently dominate the other.
627 if($request_score !== NULL && $time_score !== NULL)
628 $score = ($request_score + $time_score) / 2;
629 else if($request_score !== NULL)
630 $score = $request_score;
631 else if($time_score !== NULL)
632 $score = $time_score;
633 else
634 $score = NULL;
635
636 $latest_result = ($loads && !empty($loads[count($loads) - 1]['result'])) ? (string)$loads[count($loads) - 1]['result'] : '';
637
638 return array(
639 'request_score' => $request_score,
640 'time_score' => $time_score,
641 'score' => $score,
642 'latest_result' => $latest_result,
643 'request_count' => count($loads),
644 'time_count' => $time_count,
645 );
646 }
647
648 /**
649 * Returns the equal-block average from populated clock-aligned 10-minute blocks in the last 6 hours.
650 *
651 * Each populated 10-minute block contributes one average regardless of traffic volume. Empty
652 * blocks contribute nothing because absence of traffic is not health evidence.
653 *
654 * @package s2Member\Utilities
655 * @since 260910.0630
656 *
657 * @param array $state Asset health log.
658 * @return float|null Rolling six-hour average, or NULL without retained non-Green evidence.
659 */
660 protected static function asset_health_six_hour_average($state = array())
661 {
662 $blocks = (!empty($state['last_6hour_10min_blocks']) && is_array($state['last_6hour_10min_blocks'])) ? $state['last_6hour_10min_blocks'] : array();
663 if(!$blocks)
664 return NULL;
665 $cutoff = time() - 6 * HOUR_IN_SECONDS;
666 $total = 0.0;
667 $count = 0;
668 //260910.2346 The persistent-review calculation runs only when an admin request has already passed cheaper status/age checks; at most about 37 populated blocks can contribute.
669 foreach($blocks as $block_end => $bucket)
670 {
671 $block_end = (int)$block_end;
672 $bucket_count = (!empty($bucket['count'])) ? (int)$bucket['count'] : 0;
673 $bucket_sum = (isset($bucket['sum'])) ? (float)$bucket['sum'] : 0.0;
674 if($block_end <= $cutoff || $bucket_count < 1)
675 continue;
676 $rating = $bucket_sum / $bucket_count;
677 if($rating < 1 || $rating > 4)
678 continue;
679 $total += $rating;
680 $count++;
681 }
682 return ($count) ? $total / $count : NULL;
683 }
684
685 /**
686 * Returns a signature for compact page-load metadata used by a later Late correction.
687 *
688 * @package s2Member\Utilities
689 * @since 260910.2346
690 *
691 * @param array $load Asset-load metadata.
692 * @return string Signature.
693 */
694 protected static function asset_health_load_signature($load = array())
695 {
696 $parts = array();
697 foreach(array('load_id', 'load_time', 'orig_result', 'orig_6hour') as $key)
698 $parts[$key] = isset($load[$key]) ? (string)$load[$key] : '';
699 return hash_hmac('sha256', serialize($parts), wp_salt('nonce'));
700 }
701
702 /**
703 * Returns the current frontend page context without retaining query-string data.
704 *
705 * @package s2Member\Utilities
706 * @since 260913.0048
707 *
708 * @return array Compact page ID/path context.
709 */
710 protected static function current_asset_health_page_context()
711 {
712 $page_id = (function_exists('is_singular') && is_singular()) ? (int)get_queried_object_id() : 0;
713 $request_uri = (!empty($_SERVER['REQUEST_URI'])) ? wp_unslash((string)$_SERVER['REQUEST_URI']) : '';
714 $page_path = ($request_uri !== '') ? (string)c_ws_plugin__s2member_utils_urls::parse_url($request_uri, PHP_URL_PATH) : '';
715 $page_path = substr('/'.ltrim($page_path, '/'), 0, 240);
716 if($page_path === '/')
717 $page_path = '/';
718 return array('page_id' => max(0, $page_id), 'page_path' => $page_path);
719 }
720
721 /**
722 * Signs page context separately from legacy load metadata so already-cached pages remain compatible.
723 *
724 * @package s2Member\Utilities
725 * @since 260913.0048
726 *
727 * @param array $load Asset-load metadata.
728 * @return string Signature.
729 */
730 protected static function asset_health_page_signature($load = array())
731 {
732 $parts = array();
733 foreach(array('load_id', 'load_time', 'page_id', 'page_path') as $key)
734 $parts[$key] = isset($load[$key]) ? (string)$load[$key] : '';
735 return hash_hmac('sha256', serialize($parts), wp_salt('nonce'));
736 }
737
738 /**
739 * Returns signed page context from browser-returned load metadata.
740 *
741 * @package s2Member\Utilities
742 * @since 260913.0048
743 *
744 * @param array $load Browser-returned load metadata.
745 * @return array Verified page context, or empty context for legacy/tampered metadata.
746 */
747 protected static function verified_asset_health_page_context($load = array())
748 {
749 $load = (is_array($load)) ? $load : array();
750 if(empty($load['page_signature']) || empty($load['load_id']) || empty($load['load_time']))
751 return array('page_id' => 0, 'page_path' => '');
752 $signature = (string)$load['page_signature'];
753 if(!hash_equals(self::asset_health_page_signature($load), $signature))
754 return array('page_id' => 0, 'page_path' => '');
755 $page_id = (!empty($load['page_id'])) ? max(0, (int)$load['page_id']) : 0;
756 $page_path = (!empty($load['page_path'])) ? substr((string)$load['page_path'], 0, 240) : '';
757 return array('page_id' => $page_id, 'page_path' => $page_path);
758 }
759
760 /**
761 * Queues one frontend asset load or signed Late correction for later collection.
762 *
763 * A WordPress-rendered frontend page queues one page-level result using the worst required
764 * asset outcome: Okay=4, Late=3, Fallback=2, Failed=1. A signed Late report later corrects
765 * that original load instead of counting the same page twice.
766 *
767 * @package s2Member\Utilities
768 * @since 260910.0630
769 *
770 * @param string $result Asset-load result: `okay`, `late`, `fallback`, or `failed`.
771 * @param bool $reset_on_ok Reset prior active history when an explicit trusted recheck returns Okay.
772 * @param array $load Optional signed original-load metadata for a Late correction.
773 * @param array $issue Optional compact issue snapshot with `label` and `detail`.
774 * @return array Compact metadata for the queued load; `scores` remains an empty compatibility field.
775 */
776 protected static function queue_asset_health_load($result = '', $reset_on_ok = FALSE, $load = array(), $issue = array())
777 {
778 $result = strtolower((string)$result);
779 $rating = self::asset_health_load_rating($result);
780 if(!$rating)
781 return array('scores' => array(), 'load' => array());
782
783 $now = time();
784 $load = (is_array($load)) ? $load : array();
785 $issue = (is_array($issue)) ? $issue : array();
786 $is_late_correction = $result === 'late' && !empty($load['load_id']) && !empty($load['load_time']);
787 if(!$is_late_correction)
788 {
789 $load = array(
790 'load_id' => sha1(microtime(TRUE)."\0".wp_rand()."\0".home_url('/')),
791 'load_time' => $now,
792 'orig_result' => $result,
793 'orig_6hour' => 0,
794 );
795 //260913.0048 Frontend page identity is compact diagnostic context; keep query strings out and do not attach admin/AJAX request paths to explicit trusted rechecks.
796 if(!$reset_on_ok && !is_admin() && !(function_exists('wp_doing_ajax') && wp_doing_ajax()))
797 $load = array_merge($load, self::current_asset_health_page_context());
798 }
799
800 $event_time = ($is_late_correction && !empty($load['load_time'])) ? (int)$load['load_time'] : $now;
801 //260912.0258 Queue the event and return without reading, locking, or rewriting the shared rolling health log.
802 self::queue_asset_health_event(array(
803 'type' => 'load',
804 'event_time' => $event_time,
805 'result' => $result,
806 'reset_on_ok' => (bool)$reset_on_ok,
807 'load' => $load,
808 'issue' => $issue,
809 ));
810
811 if(!$is_late_correction)
812 {
813 $load['signature'] = self::asset_health_load_signature($load);
814 if(isset($load['page_id']) || isset($load['page_path']))
815 $load['page_signature'] = self::asset_health_page_signature($load);
816 }
817 return array('scores' => array(), 'load' => $load);
818 }
819
820 /**
821 * Queues a useful historical issue that did not itself degrade the page-level health score.
822 *
823 * @package s2Member\Utilities
824 * @since 260911.1834
825 *
826 * @param string $result Compact historical result key.
827 * @param string $label Site-owner-friendly asset label.
828 * @param string $detail Concise explanation.
829 * @param array $issue Optional asset/delivery context.
830 * @param array $page Optional page context.
831 * @param int $event_time Optional original issue timestamp.
832 * @return null
833 */
834 protected static function queue_asset_health_issue_snapshot($result = '', $label = '', $detail = '', $issue = array(), $page = array(), $event_time = 0)
835 {
836 $result = strtolower((string)$result);
837 if($result === '')
838 return;
839
840 $issue = (is_array($issue)) ? $issue : array();
841 $issue = array_merge($issue, array('label' => (string)$label, 'detail' => (string)$detail));
842 $page = (is_array($page)) ? $page : array();
843 if(!$page && !is_admin() && !(function_exists('wp_doing_ajax') && wp_doing_ajax()))
844 $page = self::current_asset_health_page_context();
845 //260913.0048 Self-repair/fallback/trusted-failure snapshots may identify the frontend page where they were encountered, without retaining its query string.
846 //260912.0258 Queue historical snapshots like scored loads so a simultaneous healthy page cannot erase them with stale state.
847 self::queue_asset_health_event(array(
848 'type' => 'issue',
849 'event_time' => ($event_time > 0) ? (int)$event_time : time(),
850 'result' => $result,
851 'issue' => $issue,
852 'page' => $page,
853 ));
854 return;
855 }
856
857 /**
858 * Applies one queued scored-load event to an already-loaded health-log state.
859 *
860 * @package s2Member\Utilities
861 * @since 260911.2356
862 *
863 * @param array $state Rolling health-log state, passed by reference.
864 * @param array $event Queued load event.
865 * @return bool True when the event was valid and consumed.
866 */
867 protected static function apply_asset_health_load_event(&$state, $event = array())
868 {
869 $event = (is_array($event)) ? $event : array();
870 $result = (!empty($event['result'])) ? strtolower((string)$event['result']) : '';
871 $rating = self::asset_health_load_rating($result);
872 if(!$rating)
873 return FALSE;
874
875 $now = time();
876 $event_time = (!empty($event['event_time'])) ? max(1, (int)$event['event_time']) : $now;
877 $reset_on_ok = !empty($event['reset_on_ok']);
878 $load = (!empty($event['load']) && is_array($event['load'])) ? $event['load'] : array();
879 $issue = (!empty($event['issue']) && is_array($event['issue'])) ? $event['issue'] : array();
880 $issues = (!empty($issue['items']) && is_array($issue['items'])) ? array_values($issue['items']) : (($issue) ? array($issue) : array());
881 $page = array('page_id' => 0, 'page_path' => '');
882
883 if($reset_on_ok && $result === 'okay')
884 {
885 //260913.0041 A trusted successful recheck resets scoring only; durable issue summaries, clear watermarks, and duplicate-processing protection remain historical state.
886 $last_issue = (!empty($state['last_issue']) && is_array($state['last_issue'])) ? $state['last_issue'] : array();
887 $latest_issues = (!empty($state['latest_issues']) && is_array($state['latest_issues'])) ? $state['latest_issues'] : array();
888 $last_issue_cleared_at = (!empty($state['last_issue_cleared_at'])) ? (int)$state['last_issue_cleared_at'] : 0;
889 $latest_issues_cleared_at = (!empty($state['latest_issues_cleared_at'])) ? (int)$state['latest_issues_cleared_at'] : 0;
890 $processed_event_times = (!empty($state['processed_event_times']) && is_array($state['processed_event_times'])) ? $state['processed_event_times'] : array();
891 $state = array('last_10_asset_loads' => array(), 'last_10min_minutes' => array(), 'last_6hour_10min_blocks' => array(), 'last_issue' => $last_issue, 'latest_issues' => $latest_issues, 'last_issue_cleared_at' => $last_issue_cleared_at, 'latest_issues_cleared_at' => $latest_issues_cleared_at, 'processed_event_times' => $processed_event_times, 'status' => 'unknown', 'not_green_since' => 0, 'history_reset_at' => $event_time);
892 delete_option('ws_plugin__s2member_asset_notice_dismissed');
893 }
894
895 $late_before_reset = FALSE;
896 $is_late_correction = $result === 'late' && !empty($load['load_id']) && !empty($load['load_time']) && !empty($load['orig_result']) && isset($load['orig_6hour']) && !empty($load['signature']);
897 if($is_late_correction)
898 {
899 $page = self::verified_asset_health_page_context($load); //260913.0048 Browser-returned page context is useful only when its separate signature matches.
900 $load['load_id'] = preg_replace('/[^a-f0-9]/', '', strtolower((string)$load['load_id']));
901 $load['load_time'] = (int)$load['load_time'];
902 $load['orig_result'] = strtolower((string)$load['orig_result']);
903 $load['orig_6hour'] = !empty($load['orig_6hour']) ? 1 : 0;
904 $signature = (string)$load['signature'];
905 unset($load['signature']);
906 $is_late_correction = strlen($load['load_id']) === 40 && $load['load_time'] > 0 && in_array($load['orig_result'], array('okay', 'fallback'), TRUE) && hash_equals(self::asset_health_load_signature($load), $signature);
907 if(!$is_late_correction)
908 $page = array('page_id' => 0, 'page_path' => '');
909 if($is_late_correction && !empty($state['history_reset_at']) && $load['load_time'] < (int)$state['history_reset_at'])
910 {
911 $late_before_reset = TRUE;
912 $is_late_correction = FALSE;
913 }
914 }
915 else
916 {
917 $page['page_id'] = (!empty($load['page_id'])) ? max(0, (int)$load['page_id']) : 0;
918 $page['page_path'] = (!empty($load['page_path'])) ? substr((string)$load['page_path'], 0, 240) : '';
919 }
920
921 if($late_before_reset)
922 {
923 foreach($issues as $late_issue)
924 if(is_array($late_issue))
925 {
926 self::add_asset_health_latest_issue($state, $load['load_time'], 'late', $late_issue, $page);
927 self::set_asset_health_last_issue($state, $load['load_time'], 'late', (!empty($late_issue['label'])) ? $late_issue['label'] : '', (!empty($late_issue['detail'])) ? $late_issue['detail'] : '', (!empty($load['load_id'])) ? $load['load_id'] : '', $page['page_id'], $page['page_path']);
928 }
929 return TRUE; //260911.2356 Old delayed reports remain useful history but never re-enter a newer scoring epoch.
930 }
931
932 $original_contributed_to_6hour = FALSE;
933 if($is_late_correction)
934 {
935 $orig_rating = self::asset_health_load_rating($load['orig_result']);
936 if($rating >= $orig_rating)
937 return TRUE; // Fallback is already worse than Late.
938 $late_key = 'ws_plugin__s2member_asset_load_late_'.$load['load_id'];
939 if(get_transient($late_key))
940 return TRUE;
941
942 $found = FALSE;
943 foreach($state['last_10_asset_loads'] as &$entry)
944 if(!empty($entry['load_id']) && hash_equals((string)$entry['load_id'], $load['load_id']))
945 {
946 $original_contributed_to_6hour = !empty($entry['orig_6hour']);
947 $entry['result'] = 'late';
948 $found = TRUE;
949 break;
950 }
951 unset($entry);
952 //260912.0258 A queued Late correction may arrive after its original load was processed, so derive six-hour membership from retained server state.
953 if(!$found && !empty($state['not_green_since']) && (int)$state['not_green_since'] <= $load['load_time'])
954 $original_contributed_to_6hour = TRUE;
955
956 $minute_end = self::asset_health_period_end($load['load_time'], MINUTE_IN_SECONDS);
957 $delta = $rating - $orig_rating;
958 if(isset($state['last_10min_minutes'][$minute_end]) && !empty($state['last_10min_minutes'][$minute_end]['count']))
959 $state['last_10min_minutes'][$minute_end]['sum'] += $delta;
960 set_transient($late_key, 1, HOUR_IN_SECONDS);
961 }
962 else
963 {
964 $load_id = (!empty($load['load_id'])) ? preg_replace('/[^a-f0-9]/', '', strtolower((string)$load['load_id'])) : '';
965 $load_id = (strlen($load_id) === 40) ? $load_id : sha1(microtime(TRUE)."\0".wp_rand()."\0".home_url('/'));
966 $load_time = (!empty($load['load_time'])) ? max(1, (int)$load['load_time']) : $event_time;
967 $load = array('load_id' => $load_id, 'load_time' => $load_time, 'orig_result' => $result, 'orig_6hour' => 0, 'page_id' => $page['page_id'], 'page_path' => $page['page_path']);
968 //260912.0551 The Health Logkeeper already processes queued loads in event-time/queue-time order; preserve that order so simultaneous same-second requests are not randomized by load ID.
969 $state['last_10_asset_loads'][] = array('time' => $load_time, 'result' => $result, 'load_id' => $load_id, 'orig_6hour' => 0);
970 $state['last_10_asset_loads'] = array_slice($state['last_10_asset_loads'], -10);
971
972 $minute_end = self::asset_health_period_end($load_time, MINUTE_IN_SECONDS);
973 if(empty($state['last_10min_minutes'][$minute_end]) || !is_array($state['last_10min_minutes'][$minute_end]))
974 $state['last_10min_minutes'][$minute_end] = array('sum' => 0.0, 'count' => 0);
975 $state['last_10min_minutes'][$minute_end]['sum'] += $rating;
976 $state['last_10min_minutes'][$minute_end]['count']++;
977 }
978
979 $current_minute_end = self::asset_health_period_end($now, MINUTE_IN_SECONDS);
980 foreach($state['last_10min_minutes'] as $minute_end => $bucket)
981 if((int)$minute_end < $current_minute_end - 9 * MINUTE_IN_SECONDS || (int)$minute_end > $current_minute_end)
982 unset($state['last_10min_minutes'][$minute_end]);
983
984 $scores = self::asset_health_scores($state);
985 $status = self::asset_health_status_from_score($scores['score'], $scores['latest_result']);
986 $previous_status = (!empty($state['status'])) ? (string)$state['status'] : 'unknown';
987 $state['status'] = $status;
988
989 if($status === 'healthy')
990 {
991 $state['not_green_since'] = 0;
992 $state['last_6hour_10min_blocks'] = array();
993 //260912.1959 Rolling delivery may be Healthy while a trusted standby fallback is still unavailable; keep that combined-health notice dismissal until the fallback recovers.
994 if(!self::asset_health_fallback_problem_active())
995 delete_option('ws_plugin__s2member_asset_notice_dismissed');
996 }
997 else
998 {
999 if(empty($state['not_green_since']))
1000 $state['not_green_since'] = ($is_late_correction) ? $load['load_time'] : $event_time;
1001 $block_time = ($is_late_correction) ? $load['load_time'] : $event_time;
1002 $block_end = self::asset_health_period_end($block_time, 10 * MINUTE_IN_SECONDS);
1003 if($is_late_correction && $original_contributed_to_6hour && isset($state['last_6hour_10min_blocks'][$block_end]) && !empty($state['last_6hour_10min_blocks'][$block_end]['count']))
1004 $state['last_6hour_10min_blocks'][$block_end]['sum'] += $rating - self::asset_health_load_rating($load['orig_result']);
1005 else if(!$is_late_correction || !$original_contributed_to_6hour)
1006 {
1007 if(empty($state['last_6hour_10min_blocks'][$block_end]) || !is_array($state['last_6hour_10min_blocks'][$block_end]))
1008 $state['last_6hour_10min_blocks'][$block_end] = array('sum' => 0.0, 'count' => 0);
1009 $state['last_6hour_10min_blocks'][$block_end]['sum'] += $rating;
1010 $state['last_6hour_10min_blocks'][$block_end]['count']++;
1011 if(!$is_late_correction)
1012 foreach($state['last_10_asset_loads'] as &$entry)
1013 if(!empty($entry['load_id']) && hash_equals((string)$entry['load_id'], (string)$load['load_id']))
1014 {
1015 $entry['orig_6hour'] = 1;
1016 break;
1017 }
1018 unset($entry);
1019 }
1020 $cutoff = $now - 6 * HOUR_IN_SECONDS;
1021 foreach($state['last_6hour_10min_blocks'] as $end => $bucket)
1022 if((int)$end <= $cutoff)
1023 unset($state['last_6hour_10min_blocks'][$end]);
1024 }
1025
1026 if($previous_status === 'healthy' && $status !== 'healthy')
1027 delete_option('ws_plugin__s2member_asset_notice_dismissed');
1028 if($result !== 'okay')
1029 {
1030 $issue_time = ($is_late_correction && !empty($load['load_time'])) ? (int)$load['load_time'] : $event_time;
1031 foreach($issues as $health_issue)
1032 if(is_array($health_issue))
1033 {
1034 self::add_asset_health_latest_issue($state, $issue_time, $result, $health_issue, $page);
1035 self::set_asset_health_last_issue($state, $issue_time, $result, (!empty($health_issue['label'])) ? $health_issue['label'] : '', (!empty($health_issue['detail'])) ? $health_issue['detail'] : '', (!empty($load['load_id'])) ? $load['load_id'] : '', $page['page_id'], $page['page_path']);
1036 }
1037 }
1038 return TRUE;
1039 }
1040
1041 /**
1042 * Synchronizes time-derived status fields after queued events are merged.
1043 *
1044 * @package s2Member\Utilities
1045 * @since 260911.2356
1046 *
1047 * @param array $state Rolling health-log state, passed by reference.
1048 * @return bool True when derived state changed.
1049 */
1050 protected static function sync_asset_health_derived_state(&$state)
1051 {
1052 $scores = self::asset_health_scores($state);
1053 $overall = self::asset_health_status_from_score($scores['score'], $scores['latest_result']);
1054 $changed = (!isset($state['status']) || (string)$state['status'] !== $overall);
1055 $state['status'] = $overall;
1056
1057 if($overall === 'healthy')
1058 {
1059 if(!empty($state['not_green_since']) || !empty($state['last_6hour_10min_blocks']))
1060 {
1061 $state['not_green_since'] = 0;
1062 $state['last_6hour_10min_blocks'] = array();
1063 $changed = TRUE;
1064 //260912.1959 Do not clear a dismissed combined-health notice while the independent fallback problem is still active.
1065 if(!self::asset_health_fallback_problem_active())
1066 delete_option('ws_plugin__s2member_asset_notice_dismissed');
1067 }
1068 }
1069 else if($overall !== 'unknown' && empty($state['not_green_since']))
1070 {
1071 $state['not_green_since'] = time();
1072 $changed = TRUE;
1073 }
1074 return $changed;
1075 }
1076
1077 /**
1078 * Runs the Health Logkeeper, merging queued frontend asset-health events into the rolling log.
1079 *
1080 * The Logkeeper never waits for another run. Frontend requests only queue separate event options,
1081 * so page delivery is never serialized behind health-log maintenance.
1082 *
1083 * @package s2Member\Utilities
1084 * @since 260912.0258
1085 *
1086 * @attaches-to ``add_action('ws_plugin__s2member_assets_health_logkeeper');``
1087 * @return int Number of queued events handled.
1088 */
1089 public static function run_health_logkeeper()
1090 {
1091 $lock = self::health_logkeeper_lock_acquire();
1092 if($lock === '')
1093 {
1094 //260912.0258 Never wait for a live Logkeeper; leave a background retry so a one-off scheduling collision cannot strand queued events.
1095 if(!wp_next_scheduled('ws_plugin__s2member_assets_health_logkeeper'))
1096 wp_schedule_single_event(time() + 30, 'ws_plugin__s2member_assets_health_logkeeper');
1097 return 0;
1098 }
1099
1100 global $wpdb;
1101 $prefix = self::asset_health_event_option_prefix();
1102 $like = $wpdb->esc_like($prefix).'%';
1103 //260912.0258 Fetch each queued option and its value in one indexed prefix query; the timestamp-based option names already provide chronological order.
1104 $rows = $wpdb->get_results($wpdb->prepare("SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_name ASC LIMIT 100", $like));
1105 $events = array();
1106 foreach((array)$rows as $row)
1107 {
1108 $option_name = (!empty($row->option_name)) ? (string)$row->option_name : '';
1109 $event_times = ($option_name !== '' && strpos($option_name, $prefix) === 0) ? substr($option_name, strlen($prefix)) : '';
1110 $event = c_ws_plugin__s2member_utils_arrays::maybe_unserialize(isset($row->option_value) ? $row->option_value : NULL);
1111 if(!preg_match('/^\\d{12}_\\d{18}$/D', $event_times) || !is_array($event))
1112 {
1113 //260912.0258 Malformed telemetry is disposable; delete it instead of carrying unexpected data into the health log.
1114 if($option_name !== '')
1115 delete_option($option_name);
1116 continue;
1117 }
1118 $events[] = array('option_name' => $option_name, 'event_times' => $event_times, 'event' => $event);
1119 }
1120
1121 $state = self::asset_health_log_state();
1122 $processed = (!empty($state['processed_event_times']) && is_array($state['processed_event_times'])) ? array_fill_keys($state['processed_event_times'], TRUE) : array();
1123 $handled = 0;
1124 $changed = FALSE;
1125 foreach($events as $queued)
1126 {
1127 $event = $queued['event'];
1128 $event_times = $queued['event_times'];
1129 if(empty($processed[$event_times]))
1130 {
1131 $type = (!empty($event['type'])) ? strtolower((string)$event['type']) : '';
1132 $valid = FALSE;
1133 if($type === 'load')
1134 $valid = self::apply_asset_health_load_event($state, $event);
1135 else if($type === 'issue' && !empty($event['result']))
1136 {
1137 $issue = (!empty($event['issue']) && is_array($event['issue'])) ? $event['issue'] : array();
1138 $page = (!empty($event['page']) && is_array($event['page'])) ? $event['page'] : array();
1139 $issue_time = (!empty($event['event_time'])) ? (int)$event['event_time'] : time();
1140 $changed = self::add_asset_health_latest_issue($state, $issue_time, (string)$event['result'], $issue, $page) || $changed;
1141 $changed = self::set_asset_health_last_issue($state, $issue_time, (string)$event['result'], (!empty($issue['label'])) ? (string)$issue['label'] : '', (!empty($issue['detail'])) ? (string)$issue['detail'] : '', '', (!empty($page['page_id'])) ? (int)$page['page_id'] : 0, (!empty($page['page_path'])) ? (string)$page['page_path'] : '') || $changed;
1142 $valid = TRUE;
1143 }
1144 if(!$valid)
1145 {
1146 delete_option($queued['option_name']);
1147 continue;
1148 }
1149 if($type === 'load')
1150 $changed = TRUE;
1151 $state['processed_event_times'][] = $event_times;
1152 $state['processed_event_times'] = array_slice(array_values(array_unique($state['processed_event_times'])), -100);
1153 $processed[$event_times] = TRUE;
1154 }
1155 $handled++;
1156 }
1157 $changed = self::sync_asset_health_derived_state($state) || $changed;
1158
1159 $stored = TRUE;
1160 if($changed || $events)
1161 {
1162 $stored = update_option('ws_plugin__s2member_assets_health_log', $state, FALSE);
1163 if(!$stored)
1164 $stored = serialize(self::asset_health_log_state()) === serialize($state); //260912.0258 update_option() also returns false when the requested value is already stored; distinguish that harmless case from a failed write before deleting queue rows.
1165 }
1166 if($stored)
1167 {
1168 //260912.0258 Delete only after the merged state and processed event-times are stored; if interrupted first, the next Logkeeper run can safely deduplicate the retained queue rows.
1169 foreach($events as $queued)
1170 delete_option($queued['option_name']);
1171 }
1172
1173 if((!$stored || count((array)$rows) >= 100) && !wp_next_scheduled('ws_plugin__s2member_assets_health_logkeeper'))
1174 wp_schedule_single_event(time() + 5, 'ws_plugin__s2member_assets_health_logkeeper');
1175 self::health_logkeeper_lock_release($lock);
1176 return ($stored) ? $handled : 0;
1177 }
1178
1179 /**
1180 * Clears one administrator-selected Asset Health troubleshooting summary without changing scoring.
1181 *
1182 * @package s2Member\Utilities
1183 * @since 260913.0056
1184 *
1185 * @return null Exits through WordPress JSON helpers.
1186 */
1187 public static function ajax_clear_asset_health_details()
1188 {
1189 if(!current_user_can('create_users'))
1190 wp_send_json_error(array('message' => 'You do not have permission to clear Asset Health details.'), 403);
1191 check_ajax_referer('ws-plugin--s2member-clear-asset-health-details');
1192 $scope = (!empty($_POST['scope'])) ? sanitize_key(wp_unslash($_POST['scope'])) : '';
1193 if(!in_array($scope, array('last_issue', 'latest_issues'), TRUE))
1194 wp_send_json_error(array('message' => 'Invalid Asset Health clear request.'), 400);
1195
1196 $lock = self::health_logkeeper_lock_acquire();
1197 if($lock === '')
1198 wp_send_json_error(array('message' => 'Asset Health is updating. Please try again.'), 409);
1199 $state = self::asset_health_log_state();
1200 $now = time();
1201 if($scope === 'last_issue')
1202 {
1203 $state['last_issue'] = array();
1204 $state['last_issue_cleared_at'] = $now;
1205 }
1206 else
1207 {
1208 $state['latest_issues'] = array();
1209 $state['latest_issues_cleared_at'] = $now;
1210 }
1211 $stored = update_option('ws_plugin__s2member_assets_health_log', $state, FALSE);
1212 if(!$stored)
1213 $stored = serialize(self::asset_health_log_state()) === serialize($state);
1214 self::health_logkeeper_lock_release($lock);
1215 if(!$stored)
1216 wp_send_json_error(array('message' => 'Asset Health details could not be cleared.'), 500);
1217 wp_send_json_success(array('message' => ($scope === 'last_issue') ? 'Last issue cleared.' : 'Latest Issues cleared.'));
1218 }
1219
1220 /**
1221 * Returns the page-level Okay/Fallback result for the delivery routes selected by WordPress.
1222 *
1223 * A normal configured route is Okay. Compatibility-required Full WordPress Dynamic delivery is
1224 * also Okay because it is the correct route for the current request/configuration. WordPress Dynamic
1225 * is Fallback only when a requested route unexpectedly could not be used. Browser activation is
1226 * checked separately by the frontend activation monitor.
1227 *
1228 * @package s2Member\Utilities
1229 * @since 260910.0630
1230 *
1231 * @return string `okay` or `fallback`.
1232 */
1233 protected static function page_asset_health_load_result()
1234 {
1235 $selected_s2o = empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress';
1236 //260910.0818 A page is Fallback only when WordPress had to choose Full WordPress Dynamic instead of a requested static or selected s2member-o.php route; intentionally selected Full WordPress Dynamic is Okay.
1237 foreach(self::$page_asset_expectations as $expectation)
1238 {
1239 $type = (!empty($expectation['type'])) ? (string)$expectation['type'] : '';
1240 if(!in_array($type, array('css', 'js'), TRUE))
1241 continue;
1242 if(!empty($expectation['delivery']) && $expectation['delivery'] === 'dynamic-wordpress' && (!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_'.$type]) || $selected_s2o))
1243 {
1244 //260913.2001 Static delivery can be intentionally incompatible with current hooks/configuration; successful required Dynamic delivery is the correct route, not a degraded fallback.
1245 if(!empty($expectation['dynamic_required']) && !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_'.$type]))
1246 continue;
1247 return 'fallback';
1248 }
1249 }
1250 return 'okay';
1251 }
1252
1253 /**
1254 * Returns compact context for a non-Okay page-level delivery result.
1255 *
1256 * @package s2Member\Utilities
1257 * @since 260911.1806
1258 *
1259 * @param string $result Page-level asset-load result.
1260 * @return array Issue snapshot with `label` and `detail`.
1261 */
1262 protected static function page_asset_health_issue($result = '')
1263 {
1264 $result = strtolower((string)$result);
1265 if($result !== 'fallback')
1266 return array();
1267
1268 $selected_s2o = empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress';
1269 foreach(self::$page_asset_expectations as $expectation)
1270 {
1271 $type = (!empty($expectation['type'])) ? (string)$expectation['type'] : '';
1272 if(!in_array($type, array('css', 'js'), TRUE) || empty($expectation['delivery']) || $expectation['delivery'] !== 'dynamic-wordpress')
1273 continue;
1274 if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_'.$type]))
1275 {
1276 $asset_id = (!empty($expectation['asset_id'])) ? (string)$expectation['asset_id'] : '';
1277 $health_id = ($asset_id !== '') ? self::asset_runtime_health_id($asset_id, $type, 'static') : '';
1278 return array(
1279 'asset' => ($health_id !== '') ? $health_id : (string)$asset_id,
1280 'label' => ($health_id !== '') ? self::asset_runtime_health_label($health_id) : strtoupper($type).' delivery',
1281 'delivery' => 'Static → Full WordPress Dynamic fallback',
1282 'detail' => (!empty($expectation['issue_detail'])) ? (string)$expectation['issue_detail'] : 'Requested static delivery was unavailable, so Full WordPress Dynamic fallback was used.',
1283 );
1284 }
1285 if($selected_s2o)
1286 return array(
1287 'asset' => 'dynamic_'.$type,
1288 'label' => 'Dynamic '.(($type === 'js') ? 'JS' : 'CSS'),
1289 'delivery' => 's2Member-Only → Full WordPress Dynamic fallback',
1290 'detail' => 'The selected s2Member-Only Dynamic Loader was unavailable, so Full WordPress Dynamic fallback was used.',
1291 );
1292 }
1293 return array();
1294 }
1295
1296 /**
1297 * Returns Okay/Fallback/Failed for the site's currently required delivery using trusted failure state.
1298 *
1299 * @package s2Member\Utilities
1300 * @since 260910.0630
1301 *
1302 * @param array $failures Trusted current probe failures.
1303 * @return string `okay`, `fallback`, or `failed`.
1304 */
1305 protected static function asset_health_current_delivery_result($failures = array())
1306 {
1307 $failures = (is_array($failures)) ? $failures : array();
1308 $selected_s2o = empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress';
1309 $local_health = self::static_assets_health(TRUE);
1310 $location = self::static_assets_location(FALSE);
1311 $overall_rating = 4;
1312
1313 //260910.0818 Trusted current delivery takes the worse CSS/JS result: 4=preferred route works, 2=WordPress fallback works, 1=no usable route verifies; Late is browser timing evidence and is not manufactured here.
1314 foreach(array('css', 'js') as $type)
1315 {
1316 $type_rating = 4;
1317 if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_'.$type]))
1318 {
1319 $dynamic_requirement = self::static_type_dynamic_requirement($type);
1320 if(!empty($dynamic_requirement['required']))
1321 $type_rating = (!empty($failures['dynamic:dynamic_'.$type])) ? 1 : 4; //260913.2001 Compatibility-required Dynamic delivery is the intended route; only failure of that route degrades Health.
1322 else
1323 {
1324 $fallback = !empty($local_health['location']);
1325 foreach(self::static_asset_ids($type, 'all') as $id)
1326 {
1327 $state = self::static_asset_build($id);
1328 $definition = self::static_asset_definition($id, FALSE);
1329 $generation_failure = get_transient('ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id));
1330 if(empty($definition['ok']) || ($generation_failure && $state <= 0) || isset($local_health[$id]))
1331 $fallback = TRUE;
1332 if($state > 0 && !empty($location['ok']))
1333 {
1334 $base = substr($id, 0, -strlen('.'.$type));
1335 $url = $location['url'].'/'.$base.'-'.$state.'.'.$type;
1336 if(!empty($failures['static:'.$id]) && !empty($failures['static:'.$id]['url']) && (string)$failures['static:'.$id]['url'] === $url)
1337 $fallback = TRUE;
1338 }
1339 }
1340 if($fallback)
1341 $type_rating = (!empty($failures['fallback:dynamic_'.$type])) ? 1 : 2;
1342 }
1343 }
1344 else if($selected_s2o)
1345 {
1346 $s2o_problem = !is_file(self::s2o_file_path()) || (!empty($failures['s2o']));
1347 if($s2o_problem)
1348 $type_rating = (!empty($failures['fallback:dynamic_'.$type])) ? 1 : 2;
1349 }
1350 else if(!empty($failures['dynamic:dynamic_'.$type]))
1351 $type_rating = 1;
1352
1353 $overall_rating = min($overall_rating, $type_rating);
1354 }
1355 return ($overall_rating <= 1) ? 'failed' : (($overall_rating === 2) ? 'fallback' : 'okay');
1356 }
1357
1358 /**
1359 * Returns recent low-trust runtime suspicions reported by real frontend pages.
1360 *
1361 * Reports are only hints. They never change delivery by themselves. A trusted administrator-browser probe must confirm the exact asset response before persistent fallback or a confirmed notice is used.
1362 *
1363 * @package s2Member\Utilities
1364 * @since 260904.2255
1365 *
1366 * @return array Current runtime suspicions.
1367 */
1368 protected static function asset_runtime_suspicions()
1369 {
1370 $suspicions = get_option('ws_plugin__s2member_asset_runtime_suspicions', array());
1371 $suspicions = (is_array($suspicions)) ? $suspicions : array();
1372 foreach($suspicions as $key => $suspicion)
1373 if(empty($suspicion['reported']) || (int)$suspicion['reported'] < time() - HOUR_IN_SECONDS)
1374 unset($suspicions[$key]);
1375 return $suspicions;
1376 }
1377
1378 /**
1379 * Returns the current public asset URLs that an administrator's browser should probe.
1380 *
1381 * 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 activation-tag check for the exact asset that page expected.
1382 * A full Health-panel/recheck probe additionally verifies activation tags for the configured dynamic route,
1383 * the WordPress Dynamic fallback, and static files so it can produce a fresh Okay/Fallback/Failed asset-load result.
1384 *
1385 * @package s2Member\Utilities
1386 * @since 260904.2110
1387 *
1388 * @param bool $full Include current-delivery/fallback activation-tag checks for a fresh asset-load health result.
1389 * @return array Health targets keyed by logical target ID.
1390 */
1391 protected static function asset_http_health_targets($full = FALSE)
1392 {
1393 $targets = array();
1394 $selected_s2o = empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress';
1395 if($selected_s2o && is_file(self::s2o_file_path()))
1396 $targets['s2o'] = array(
1397 'id' => 's2o',
1398 'url' => $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'],
1399 'probe_url' => add_query_arg('s2member_health_check', '1', $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url']),
1400 'type' => 'health',
1401 'mode' => 's2o-health',
1402 'label' => 's2Member-Only Dynamic Loader',
1403 'failure_id' => 's2o',
1404 'failure_url' => $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'],
1405 );
1406
1407 $location = self::static_assets_location(FALSE);
1408 if(!empty($location['ok']))
1409 foreach(array('css' => 'static_css', 'js' => 'static_js') as $type => $option)
1410 if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o'][$option]))
1411 {
1412 $dynamic_requirement = self::static_type_dynamic_requirement($type);
1413 if(!empty($dynamic_requirement['required']))
1414 continue; //260913.2001 Intentionally inactive static files are not trusted-probe targets while compatibility requires Dynamic delivery.
1415 foreach(self::static_asset_ids($type, 'all') as $id)
1416 {
1417 $build = self::static_asset_build($id);
1418 if($build <= 0)
1419 continue;
1420 $base = substr($id, 0, -strlen('.'.$type));
1421 $url = $location['url'].'/'.$base.'-'.$build.'.'.$type;
1422 if(is_file($location['dir'].'/'.$base.'-'.$build.'.'.$type))
1423 {
1424 $target = array(
1425 'id' => 'static:'.$id,
1426 'url' => $url,
1427 'probe_url' => $url,
1428 'type' => $type,
1429 'mode' => ($full) ? 'activation-tag' : 'head',
1430 'label' => self::asset_runtime_health_label(self::asset_runtime_health_id($id, $type, 'static')),
1431 'failure_id' => 'static:'.$id,
1432 'failure_url' => $url,
1433 );
1434 if($full)
1435 {
1436 //260912.0522 Full probes validate the activation tag too; cheap background probes stay HEAD-only to avoid unnecessary body downloads.
1437 $health_id = self::asset_runtime_health_id($id, $type, 'static');
1438 $tag_value = ($type === 'css') ? (string)(int)$build : 'static-'.(int)$build;
1439 $target['activation_tags'] = array(self::activation_tag_snippet($health_id, $type, $tag_value));
1440 }
1441 $targets['static:'.$id] = $target;
1442 }
1443 }
1444 }
1445
1446 if($full)
1447 {
1448 //260910.0818 A full trusted check includes the actual dynamic response and its available WordPress fallback so Fallback can be distinguished from Failed instead of assuming a fallback works.
1449 foreach(array('css', 'js') as $type)
1450 {
1451 $health_id = 'dynamic_'.$type;
1452 $wordpress_url = self::wordpress_dynamic_asset_url();
1453 $wordpress_url = ($type === 'css')
1454 ? add_query_arg(array('ws_plugin__s2member_css' => '1', 'qcABC' => '1'), $wordpress_url)
1455 : add_query_arg(array('ws_plugin__s2member_js_w_globals' => '1', 'qcABC' => '1'), $wordpress_url);
1456 $wordpress_tag_value = ($type === 'css') ? '2147483640' : 'dynamic-wordpress';
1457 $wordpress_target = array(
1458 'url' => $wordpress_url,
1459 'probe_url' => $wordpress_url,
1460 'type' => $type,
1461 'mode' => 'activation-tag',
1462 'label' => 'WP Loader '.(($type === 'css') ? 'CSS' : 'JS'),
1463 'activation_tags' => array(self::activation_tag_snippet($health_id, $type, $wordpress_tag_value)),
1464 );
1465
1466 if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_'.$type]))
1467 {
1468 $dynamic_requirement = self::static_type_dynamic_requirement($type);
1469 //260913.2001 Probe compatibility-required Full WordPress Dynamic as the active route; genuine static failures keep the existing fallback probe identity.
1470 $target_id = (!empty($dynamic_requirement['required'])) ? 'active:'.$health_id : 'fallback:'.$health_id;
1471 $wordpress_target['id'] = $target_id;
1472 $wordpress_target['failure_id'] = (!empty($dynamic_requirement['required'])) ? 'dynamic:'.$health_id : $target_id;
1473 $wordpress_target['failure_url'] = $wordpress_url;
1474 $targets[$target_id] = $wordpress_target;
1475 }
1476 else if($selected_s2o)
1477 {
1478 if(is_file(self::s2o_file_path()))
1479 {
1480 $s2o_url = ($type === 'css')
1481 ? add_query_arg(array('ws_plugin__s2member_css' => '1', 'qcABC' => '1'), $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'])
1482 : add_query_arg(array('ws_plugin__s2member_js_w_globals' => '1', 'qcABC' => '1'), $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url']);
1483 $s2o_tag_value = ($type === 'css') ? '2147483639' : 'dynamic-s2member-o';
1484 $target_id = 'active:s2member-o:'.$health_id;
1485 $targets[$target_id] = array(
1486 'id' => $target_id,
1487 'url' => $s2o_url,
1488 'probe_url' => $s2o_url,
1489 'type' => $type,
1490 'mode' => 'activation-tag',
1491 'label' => 's2Member-Only '.(($type === 'css') ? 'CSS' : 'JS'),
1492 'activation_tags' => array(self::activation_tag_snippet($health_id, $type, $s2o_tag_value)),
1493 'failure_id' => 's2o',
1494 'failure_url' => $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'],
1495 );
1496 }
1497 $target_id = 'fallback:'.$health_id;
1498 $wordpress_target['id'] = $target_id;
1499 $wordpress_target['failure_id'] = $target_id;
1500 $wordpress_target['failure_url'] = $wordpress_url;
1501 $targets[$target_id] = $wordpress_target;
1502 }
1503 else
1504 {
1505 $target_id = 'active:'.$health_id;
1506 $wordpress_target['id'] = $target_id;
1507 $wordpress_target['failure_id'] = 'dynamic:'.$health_id;
1508 $wordpress_target['failure_url'] = $wordpress_url;
1509 $targets[$target_id] = $wordpress_target;
1510 }
1511 }
1512 }
1513
1514 //260912.0522 Real-page Late reports remain low-trust hints; add exact URL/activation-tag targets so the administrator-browser probe can confirm or reject them without changing delivery from the report alone.
1515 foreach(self::asset_runtime_suspicions() as $key => $suspicion)
1516 {
1517 //260912.0522 Ignore pre-rename in-flight suspicions instead of carrying a compatibility alias for this new Beta schema.
1518 if(empty($suspicion['activation_tag']) || !self::asset_runtime_expectation_is_current($suspicion))
1519 continue;
1520 $id = 'runtime:'.$key;
1521 $failure_id = '';
1522 $failure_url = (string)$suspicion['url'];
1523 if($suspicion['delivery'] === 'dynamic-s2member-o')
1524 {
1525 $failure_id = 's2o';
1526 $failure_url = $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'];
1527 }
1528 else if($suspicion['delivery'] === 'static' && !empty($suspicion['asset_id']))
1529 $failure_id = 'static:'.$suspicion['asset_id'];
1530 else
1531 $failure_id = 'dynamic:'.(string)$suspicion['id'];
1532
1533 $targets[$id] = array(
1534 'id' => $id,
1535 'url' => (string)$suspicion['url'],
1536 'probe_url' => (string)$suspicion['url'],
1537 'type' => (string)$suspicion['type'],
1538 'mode' => 'activation-tag',
1539 'label' => self::asset_runtime_health_label((string)$suspicion['id']),
1540 'activation_tags' => array((string)$suspicion['activation_tag']),
1541 'failure_id' => $failure_id,
1542 'failure_url' => $failure_url,
1543 'suspicion_key' => $key,
1544 'suspicion' => $suspicion,
1545 );
1546 }
1547 return $targets;
1548 }
1549
1550 /**
1551 * Returns a stable hash for the current browser health targets.
1552 *
1553 * @package s2Member\Utilities
1554 * @since 260904.2110
1555 *
1556 * @param array $targets Current health targets.
1557 * @return string Target hash.
1558 */
1559 protected static function asset_http_health_target_hash($targets = array())
1560 {
1561 $hash = array();
1562 foreach((array)$targets as $id => $target)
1563 $hash[$id] = array((string)$target['url'], (string)$target['type'], (string)$target['mode'], (!empty($target['activation_tags'])) ? array_values((array)$target['activation_tags']) : array());
1564 return md5(serialize($hash));
1565 }
1566
1567 /**
1568 * Upgrades the frontend Asset Health format once per site.
1569 *
1570 * Existing timestamped files remain available to already-cached HTML, while fresh pages
1571 * regenerate active static assets with one activation tag per physical response.
1572 *
1573 * @package s2Member\Utilities
1574 * @since 260909.2015
1575 *
1576 * @return null
1577 */
1578 public static function maybe_upgrade_asset_health_format()
1579 {
1580 if((string)get_option('ws_plugin__s2member_asset_health_format_version', '') === '3')
1581 return;
1582
1583 //260912.0522 Reset the first-v260909 activation-tag/build and health-history state together so old component tags and pre-score load history cannot bleed into the physical-file scoring model.
1584 self::reset_static_asset_builds();
1585 delete_option('ws_plugin__s2member_asset_runtime_suspicions');
1586 delete_option('ws_plugin__s2member_asset_http_health');
1587 delete_option('ws_plugin__s2member_asset_attention_state');
1588 delete_option('ws_plugin__s2member_assets_health_log');
1589 delete_option('ws_plugin__s2member_asset_notice_dismissed');
1590 self::$asset_http_health_cache = NULL;
1591 update_option('ws_plugin__s2member_asset_health_format_version', '3', FALSE);
1592 return;
1593 }
1594
1595 /**
1596 * Returns the runtime-health ID for one physical frontend asset response.
1597 *
1598 * @package s2Member\Utilities
1599 * @since 260909.2015
1600 *
1601 * @param string $asset_id Static logical filename, or an empty string for dynamic delivery.
1602 * @param string $type `css` or `js`.
1603 * @param string $delivery Delivery mode.
1604 * @return string Runtime-health ID.
1605 */
1606 protected static function asset_runtime_health_id($asset_id = '', $type = '', $delivery = '')
1607 {
1608 $type = strtolower((string)$type);
1609 if(!in_array($type, array('css', 'js'), TRUE))
1610 return '';
1611 if($delivery === 'static')
1612 {
1613 $base = substr((string)$asset_id, 0, -strlen('.'.$type));
1614 $base = str_replace('-', '_', strtolower($base));
1615 return ($base) ? $base.'_'.$type : '';
1616 }
1617 return 'dynamic_'.$type;
1618 }
1619
1620 /**
1621 * Returns a site-owner-friendly label for one runtime-health ID.
1622 *
1623 * @package s2Member\Utilities
1624 * @since 260909.2015
1625 *
1626 * @param string $id Runtime-health ID.
1627 * @return string Human-readable label.
1628 */
1629 protected static function asset_runtime_health_label($id = '')
1630 {
1631 $combined = !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_assets_combine']) && (defined('WS_PLUGIN__S2MEMBER_PRO_VERSION') || isset($GLOBALS['WS_PLUGIN__']['s2member_pro']));
1632 $labels = array(
1633 's2member_css' => ($combined) ? 'Combined CSS' : 'Framework CSS',
1634 's2member_pro_css' => 'Pro CSS',
1635 's2member_js' => ($combined) ? 'Combined JS' : 'Framework JS',
1636 's2member_pro_js' => 'Pro JS',
1637 'dynamic_css' => 'Dynamic CSS',
1638 'dynamic_js' => 'Dynamic JS',
1639 );
1640 return isset($labels[$id]) ? $labels[$id] : (string)$id;
1641 }
1642
1643 /**
1644 * Returns the activation-tag snippet used to verify one physical frontend asset response.
1645 *
1646 * @package s2Member\Utilities
1647 * @since 260909.2015
1648 *
1649 * @param string $id Runtime-health ID.
1650 * @param string $type `css` or `js`.
1651 * @param string $tag_value Value the activation tag is expected to expose.
1652 * @return string Activation-tag source snippet.
1653 */
1654 protected static function activation_tag_snippet($id = '', $type = '', $tag_value = '')
1655 {
1656 if($type === 'css')
1657 return '#ws-plugin--s2member-asset-health-'.str_replace('_', '-', (string)$id).'{z-index:'.(string)$tag_value.'!important}';
1658 return 'ws_plugin__s2member_asset_health["'.(string)$id.'"]="'.(string)$tag_value.'"';
1659 }
1660
1661 /**
1662 * Returns the activation-tag snippet appended to a generated static asset.
1663 *
1664 * @package s2Member\Utilities
1665 * @since 260904.2255
1666 *
1667 * @param string $id Stable generated asset identifier without extension.
1668 * @param string $type `css` or `js`.
1669 * @param int $build Generated build timestamp.
1670 * @return string Activation-tag snippet.
1671 */
1672 protected static function static_activation_tag_snippet($id = '', $type = '', $build = 0)
1673 {
1674 //260912.0522 Activation-tag identity follows the physical response, not Framework/Pro logical components, so a combined file produces one tag and one possible Late report.
1675 $health_id = self::asset_runtime_health_id($id.'.'.$type, $type, 'static');
1676 $tag_value = ($type === 'css') ? (string)(int)$build : 'static-'.(int)$build;
1677 $activation_tag = self::activation_tag_snippet($health_id, $type, $tag_value);
1678 if($type === 'css')
1679 return $activation_tag;
1680 return ';window.ws_plugin__s2member_asset_health=window.ws_plugin__s2member_asset_health||{};window.'.$activation_tag.';';
1681 }
1682
1683 /**
1684 * Returns the activation-tag snippet appended to dynamically generated CSS or JavaScript.
1685 *
1686 * @package s2Member\Utilities
1687 * @since 260904.2255
1688 *
1689 * @param string $type `css` or `js`.
1690 * @return string Activation-tag snippet.
1691 */
1692 public static function dynamic_activation_tag_snippet($type = '')
1693 {
1694 $type = strtolower((string)$type);
1695 if(!in_array($type, array('css', 'js'), TRUE))
1696 return '';
1697 $delivery = (defined('_WS_PLUGIN__S2MEMBER_ONLY')) ? 'dynamic-s2member-o' : 'dynamic-wordpress';
1698 $health_id = self::asset_runtime_health_id('', $type, $delivery);
1699 $tag_value = ($type === 'css') ? (($delivery === 'dynamic-s2member-o') ? '2147483639' : '2147483640') : $delivery;
1700 $activation_tag = self::activation_tag_snippet($health_id, $type, $tag_value);
1701 if($type === 'css')
1702 return "\n".$activation_tag."\n";
1703 return "\n;window.ws_plugin__s2member_asset_health=window.ws_plugin__s2member_asset_health||{};window.".$activation_tag.";\n";
1704 }
1705
1706 /**
1707 * Registers the exact CSS/JavaScript activation tags expected on the current frontend page.
1708 * Each call represents one physical response, so combined Framework+Pro delivery registers one activation tag for that combined file instead of one tag per logical component.
1709 *
1710 * @package s2Member\Utilities
1711 * @since 260904.2255
1712 *
1713 * @param string $asset_id Logical static asset ID, or an empty string for dynamic delivery.
1714 * @param string $type `css` or `js`.
1715 * @param string $url Public URL emitted on this page.
1716 * @param string $delivery `static`, `dynamic-s2member-o`, or `dynamic-wordpress`.
1717 * @param int $build Static build timestamp, or zero for dynamic delivery.
1718 * @param string $issue_detail Optional reason a preferred route fell back before this response was selected.
1719 * @param bool $dynamic_required Whether Full WordPress Dynamic delivery is intentionally required for compatibility.
1720 * @return null
1721 */
1722 public static function register_page_asset_expectations($asset_id = '', $type = '', $url = '', $delivery = '', $build = 0, $issue_detail = '', $dynamic_required = FALSE)
1723 {
1724 $type = strtolower((string)$type);
1725 $url = (string)$url;
1726 if(!in_array($type, array('css', 'js'), TRUE) || !$url || !in_array($delivery, array('static', 'dynamic-s2member-o', 'dynamic-wordpress'), TRUE))
1727 return;
1728 $id = self::asset_runtime_health_id($asset_id, $type, $delivery);
1729 if(!$id)
1730 return;
1731 if($delivery === 'static')
1732 $tag_value = ($type === 'css') ? (string)(int)$build : 'static-'.(int)$build;
1733 else
1734 $tag_value = ($type === 'css') ? (($delivery === 'dynamic-s2member-o') ? '2147483639' : '2147483640') : $delivery;
1735 $expectation = array(
1736 'id' => $id,
1737 'asset_id' => (string)$asset_id,
1738 'type' => $type,
1739 'url' => $url,
1740 'delivery' => $delivery,
1741 'tag_value' => $tag_value,
1742 'activation_tag' => self::activation_tag_snippet($id, $type, $tag_value),
1743 //260911.1806 Server-side fallback context is not sent to the browser; it only supplies a useful Last issue snapshot for the page that selected fallback.
1744 'issue_detail' => substr(wp_strip_all_tags((string)$issue_detail), 0, 240),
1745 //260913.2001 Server-only compatibility context keeps intentional Full WordPress Dynamic delivery Healthy without changing the compact browser expectation/signature.
1746 'dynamic_required' => (bool)$dynamic_required,
1747 );
1748 $expectation['signature'] = self::asset_runtime_expectation_signature($expectation);
1749 self::$page_asset_expectations[$id] = $expectation;
1750 return;
1751 }
1752
1753 /**
1754 * Expands one compact browser runtime expectation into the full signed structure.
1755 *
1756 * Frontend pages only need a few fields to check asset activation. Reconstruct the
1757 * descriptive fields here when a miss is actually reported, keeping healthy page source small.
1758 * Recovery fields from the first v260909 monitor are intentionally no longer part of current expectations because Late results no longer trigger speculative fallback injection.
1759 *
1760 * @package s2Member\Utilities
1761 * @since 260905.0106
1762 *
1763 * @param array $compact Compact expectation fields.
1764 * @return array Full expectation, or an empty array when invalid.
1765 */
1766 protected static function expand_asset_runtime_expectation($compact = array())
1767 {
1768 if(!is_array($compact) || count($compact) < 6)
1769 return array();
1770 $id = isset($compact[0]) ? (string)$compact[0] : '';
1771 $asset_id = isset($compact[1]) ? (string)$compact[1] : '';
1772 $url = isset($compact[2]) ? (string)$compact[2] : '';
1773 $delivery = isset($compact[3]) ? (string)$compact[3] : '';
1774 $tag_value = isset($compact[4]) ? (string)$compact[4] : '';
1775 $signature = isset($compact[5]) ? (string)$compact[5] : '';
1776 if(!preg_match('/\A(?:s2member(?:_pro)?|dynamic)_(css|js)\z/', $id, $match))
1777 return array(); //260912.0522 Cached pages using the first-v260909 component-level activation-tag IDs are intentionally stale after the Asset Health format upgrade.
1778 $type = $match[1];
1779 return array(
1780 'id' => $id,
1781 'asset_id' => $asset_id,
1782 'type' => $type,
1783 'url' => $url,
1784 'delivery' => $delivery,
1785 'tag_value' => $tag_value,
1786 'activation_tag' => self::activation_tag_snippet($id, $type, $tag_value),
1787 'signature' => $signature,
1788 );
1789 }
1790
1791 /**
1792 * Returns a signature for one low-trust runtime expectation report.
1793 *
1794 * @package s2Member\Utilities
1795 * @since 260904.2255
1796 *
1797 * @param array $expectation Runtime expectation fields.
1798 * @return string Signature.
1799 */
1800 protected static function asset_runtime_expectation_signature($expectation = array())
1801 {
1802 $parts = array();
1803 foreach(array('id', 'asset_id', 'type', 'url', 'delivery', 'tag_value', 'activation_tag') as $key)
1804 $parts[$key] = isset($expectation[$key]) ? (string)$expectation[$key] : '';
1805 return hash_hmac('sha256', serialize($parts), wp_salt('nonce'));
1806 }
1807
1808 /**
1809 * Returns true when a reported expectation still describes the site's current delivery state.
1810 *
1811 * @package s2Member\Utilities
1812 * @since 260904.2255
1813 *
1814 * @param array $expectation Runtime expectation.
1815 * @return bool True when current.
1816 */
1817 protected static function asset_runtime_expectation_is_current($expectation = array())
1818 {
1819 if(empty($expectation['url']) || empty($expectation['delivery']) || empty($expectation['type']))
1820 return FALSE;
1821 if($expectation['delivery'] === 'static')
1822 {
1823 $id = (string)$expectation['asset_id'];
1824 $type = (string)$expectation['type'];
1825 if(!in_array($id, self::static_asset_ids($type, 'all'), TRUE))
1826 return FALSE;
1827 $build = self::static_asset_build($id);
1828 $location = self::static_assets_location(FALSE);
1829 $base = substr($id, 0, -strlen('.'.$type));
1830 return !empty($location['ok']) && $build > 0 && (string)$expectation['url'] === $location['url'].'/'.$base.'-'.$build.'.'.$type;
1831 }
1832 if($expectation['delivery'] === 'dynamic-s2member-o')
1833 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;
1834 if($expectation['delivery'] === 'dynamic-wordpress')
1835 return strpos((string)$expectation['url'], self::wordpress_dynamic_asset_url().'?') === 0;
1836 return FALSE;
1837 }
1838
1839 /**
1840 * Returns active generated frontend asset IDs for one type/component.
1841 *
1842 * Framework and Pro files stay separate by default. Combined mode reuses the Framework ID because that file becomes the combined representation.
1843 *
1844 * @package s2Member\Utilities
1845 * @since 260903.1918
1846 *
1847 * @param string $type `css` or `js`.
1848 * @param string $component `all`, `framework`, or `pro`.
1849 * @return array Logical generated filenames.
1850 */
1851 public static function static_asset_ids($type = '', $component = 'all')
1852 {
1853 $type = strtolower((string)$type);
1854 $component = strtolower((string)$component);
1855 if(!in_array($type, array('css', 'js'), TRUE) || !in_array($component, array('all', 'framework', 'pro'), TRUE))
1856 return array();
1857
1858 $framework = 's2member.'.$type;
1859 $pro = (!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_assets_combine'])) ? $framework : 's2member-pro.'.$type;
1860 if($component === 'framework')
1861 return array($framework);
1862 if($component === 'pro')
1863 return array($pro);
1864
1865 $ids = array($framework);
1866 if((defined('WS_PLUGIN__S2MEMBER_PRO_VERSION') || isset($GLOBALS['WS_PLUGIN__']['s2member_pro'])) && $pro !== $framework)
1867 $ids[] = $pro;
1868 return $ids;
1869 }
1870
1871 /**
1872 * Returns how s2Member text used by static JavaScript should be delivered.
1873 *
1874 * @package s2Member\Utilities
1875 * @since 260906.2049
1876 *
1877 * @return string `static` to include text in generated JavaScript, or `page` to load it with each WordPress page.
1878 */
1879 public static function static_js_text_delivery()
1880 {
1881 return (!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_js_text']) && $GLOBALS['WS_PLUGIN__']['s2member']['o']['static_js_text'] === 'page') ? 'page' : 'static';
1882 }
1883
1884 /**
1885 * Determines whether page-loaded JavaScript text is supported by the active Framework/Pro combination.
1886 *
1887 * Framework can always load its own text with the page. Pro explicitly advertises support because
1888 * older Pro releases predate the shipped static-JS data map needed for this delivery mode.
1889 *
1890 * @package s2Member\Utilities
1891 * @since 260906.2049
1892 *
1893 * @return bool True if page-loaded JavaScript text is available.
1894 */
1895 public static function static_js_page_text_supported()
1896 {
1897 if(!c_ws_plugin__s2member_utils_conds::pro_is_installed())
1898 return TRUE;
1899 return (bool)apply_filters('ws_plugin__s2member_static_js_page_text_supported', FALSE);
1900 }
1901
1902 /**
1903 * Determines whether the active Pro JavaScript hook contains only built-in callbacks safe to cache in a static file.
1904 *
1905 * This lets a newer Framework retain static-file delivery with older Pro releases that predate
1906 * the static-source filters. Unknown, reordered, or deprecated gateway callbacks remain dynamic.
1907 *
1908 * @package s2Member\Utilities
1909 * @since 260906.2049
1910 *
1911 * @return bool True if the active Pro hook can be captured safely.
1912 */
1913 protected static function static_js_builtin_pro_callbacks_supported()
1914 {
1915 if(!c_ws_plugin__s2member_utils_conds::pro_is_installed() || has_filter('ws_plugin__s2member_pro_available_gateways'))
1916 return FALSE;
1917 $built_ins = array(
1918 'c_ws_plugin__s2member_pro_css_js::js_w_globals',
1919 'c_ws_plugin__s2member_pro_paypal_css_js::paypal_js_w_globals',
1920 'c_ws_plugin__s2member_pro_stripe_css_js::stripe_js_w_globals',
1921 'c_ws_plugin__s2member_pro_authnet_css_js::authnet_js_w_globals',
1922 'c_ws_plugin__s2member_pro_clickbank_css_js::clickbank_js_w_globals',
1923 );
1924 $callbacks = isset($GLOBALS['wp_filter']['ws_plugin__s2member_during_js_w_globals']) ? $GLOBALS['wp_filter']['ws_plugin__s2member_during_js_w_globals'] : array();
1925 if(is_object($callbacks) && isset($callbacks->callbacks))
1926 $callbacks = $callbacks->callbacks;
1927 $found = FALSE;
1928 foreach((array)$callbacks as $priority => $priority_callbacks)
1929 foreach((array)$priority_callbacks as $callback)
1930 {
1931 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)
1932 return FALSE;
1933 $found = TRUE;
1934 }
1935 return $found;
1936 }
1937
1938 /**
1939 * Captures built-in Pro JavaScript from an older Pro release for static-file text delivery.
1940 *
1941 * @package s2Member\Utilities
1942 * @since 260906.2049
1943 *
1944 * @return string Captured Pro JavaScript, or an empty string if the hook is not safely cacheable.
1945 */
1946 protected static function static_js_builtin_pro_output()
1947 {
1948 if(!self::static_js_builtin_pro_callbacks_supported())
1949 return '';
1950 //260906.2256 Match the template variables passed by the normal dynamic loader when capturing older Pro callbacks.
1951 $u = $GLOBALS['WS_PLUGIN__']['s2member']['c']['dir_url'];
1952 $i = $u.'/src/images';
1953 ob_start();
1954 do_action('ws_plugin__s2member_during_js_w_globals', get_defined_vars());
1955 return (string)ob_get_clean();
1956 }
1957
1958 /**
1959 * Returns shipped static JavaScript data maps used by one generated static JavaScript file.
1960 *
1961 * Framework owns its data map. Pro appends its independent data map through a filter so the two
1962 * release packages never need cross-repo slot coordination.
1963 *
1964 * @package s2Member\Utilities
1965 * @since 260906.0738
1966 *
1967 * @param string $id Logical generated JavaScript filename.
1968 * @return array Data-map paths keyed by the compact browser-data namespace.
1969 */
1970 protected static function static_js_data_map_paths($id = '')
1971 {
1972 if(self::static_js_text_delivery() !== 'page')
1973 return array();
1974 $id = strtolower((string)$id);
1975 $paths = array();
1976 if($id === 's2member.js')
1977 $paths['f'] = $GLOBALS['WS_PLUGIN__']['s2member']['c']['dir'].'/src/includes/s2member.js.php';
1978 $paths = (array)apply_filters('ws_plugin__s2member_static_js_data_map_paths', $paths, $id, get_defined_vars());
1979 foreach($paths as $key => $path)
1980 if(!preg_match('/^[a-z][a-z0-9_]*$/i', (string)$key) || !(string)$path)
1981 unset($paths[$key]);
1982 return $paths;
1983 }
1984
1985 /**
1986 * Parses one shipped static JavaScript data map into an exact expression-to-slot lookup.
1987 *
1988 * @package s2Member\Utilities
1989 * @since 260906.0738
1990 *
1991 * @param string $path Data-map path.
1992 * @return array Parse result.
1993 */
1994 protected static function static_js_data_map($path = '')
1995 {
1996 $path = (string)$path;
1997 if(isset(self::$static_js_data_map_cache[$path]))
1998 return self::$static_js_data_map_cache[$path];
1999 if(!$path || !is_readable($path) || ($source = file_get_contents($path)) === FALSE)
2000 return self::$static_js_data_map_cache[$path] = array('ok' => FALSE, 'slots' => array(), 'hash' => '', 'error' => 'Static JavaScript data map is not readable: '.$path);
2001
2002 $slots = array();
2003 if(!preg_match_all('/\\$data\\[(\\d+)\\]\\s*=\\s*\\/\\*d\\*\\/(.*?)\\/\\*b\\*\\/;/s', $source, $matches, PREG_SET_ORDER))
2004 return self::$static_js_data_map_cache[$path] = array('ok' => FALSE, 'slots' => array(), 'hash' => '', 'error' => 'Static JavaScript data map contains no marked entries: '.$path);
2005 foreach($matches as $index => $match)
2006 {
2007 $slot = (int)$match[1];
2008 $expression = trim((string)$match[2]);
2009 if($slot !== $index || !$expression || isset($slots[$expression]))
2010 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);
2011 $slots[$expression] = $slot;
2012 }
2013 return self::$static_js_data_map_cache[$path] = array('ok' => TRUE, 'slots' => $slots, 'hash' => hash('sha256', $source), 'error' => '');
2014 }
2015
2016 /**
2017 * Returns the current shipped data-map signature for one static JavaScript representation.
2018 *
2019 * @package s2Member\Utilities
2020 * @since 260906.0738
2021 *
2022 * @param string $id Logical generated JavaScript filename.
2023 * @return array Signature result.
2024 */
2025 protected static function static_js_data_map_signature($id = '')
2026 {
2027 $hashes = array();
2028 foreach(self::static_js_data_map_paths($id) as $key => $path)
2029 {
2030 $data_map = self::static_js_data_map($path);
2031 if(empty($data_map['ok']))
2032 return array('ok' => FALSE, 'signature' => '', 'error' => (string)$data_map['error']);
2033 $hashes[(string)$key] = (string)$data_map['hash'];
2034 }
2035 if(!$hashes)
2036 return array('ok' => FALSE, 'signature' => '', 'error' => 'No static JavaScript data map is available for '.$id);
2037 return array('ok' => TRUE, 'signature' => hash('sha256', wp_json_encode($hashes)), 'error' => '');
2038 }
2039
2040 /**
2041 * Returns the data-map signature saved with the active generated JavaScript file.
2042 *
2043 * @package s2Member\Utilities
2044 * @since 260906.0738
2045 *
2046 * @param string $id Logical generated JavaScript filename.
2047 * @return string Saved signature.
2048 */
2049 protected static function static_asset_data_map_signature($id = '')
2050 {
2051 $signatures = get_option('ws_plugin__s2member_static_asset_data_map_signatures', array());
2052 return (is_array($signatures) && isset($signatures[$id])) ? (string)$signatures[$id] : '';
2053 }
2054
2055 /**
2056 * Saves the data-map signature paired with one generated JavaScript file.
2057 *
2058 * @package s2Member\Utilities
2059 * @since 260906.0738
2060 *
2061 * @param string $id Logical generated JavaScript filename.
2062 * @param string $signature Current data-map signature.
2063 * @return null
2064 */
2065 protected static function set_static_asset_data_map_signature($id = '', $signature = '')
2066 {
2067 if(!in_array($id, array('s2member.js', 's2member-pro.js'), TRUE))
2068 return;
2069 $signatures = get_option('ws_plugin__s2member_static_asset_data_map_signatures', array());
2070 $signatures = is_array($signatures) ? $signatures : array();
2071 $signatures[$id] = (string)$signature;
2072 update_option('ws_plugin__s2member_static_asset_data_map_signatures', $signatures);
2073 return;
2074 }
2075
2076 /**
2077 * Returns the signed build timestamp for one generated frontend asset file.
2078 *
2079 * Positive values are current. Negative values preserve the previous timestamp while marking that exact file stale.
2080 *
2081 * @package s2Member\Utilities
2082 * @since 260903.0525
2083 *
2084 * @param string $id Logical generated filename, e.g. `s2member.js` or `s2member-pro.css`.
2085 * @return int Signed build timestamp.
2086 */
2087 public static function static_asset_build($id = '')
2088 {
2089 $id = strtolower((string)$id);
2090 if(in_array($id, array('css', 'js'), TRUE))
2091 $id = 's2member.'.$id;
2092 $builds = get_option('ws_plugin__s2member_static_asset_builds', array());
2093 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;
2094 }
2095
2096 /**
2097 * Updates the signed build timestamp for one generated frontend asset file.
2098 *
2099 * @package s2Member\Utilities
2100 * @since 260903.0525
2101 *
2102 * @param string $id Logical generated filename.
2103 * @param int $build Signed build timestamp.
2104 * @return null
2105 */
2106 protected static function set_static_asset_build($id = '', $build = 0)
2107 {
2108 $id = strtolower((string)$id);
2109 if(!in_array($id, array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js'), TRUE))
2110 return;
2111 $builds = get_option('ws_plugin__s2member_static_asset_builds', array());
2112 $builds = is_array($builds) ? $builds : array();
2113 //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.
2114 $builds = array_intersect_key($builds, array_flip(array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js')));
2115 $builds[$id] = (int)$build;
2116 update_option('ws_plugin__s2member_static_asset_builds', $builds);
2117 unset(self::$static_asset_cache[$id]);
2118 self::$static_assets_health_cache = NULL;
2119 delete_option('ws_plugin__s2member_static_asset_health');
2120 return;
2121 }
2122
2123 /**
2124 * Clears generated frontend asset build state when the active file representation changes.
2125 *
2126 * Existing timestamped files remain on disk for already-cached HTML; fresh requests generate only the newly active representation.
2127 *
2128 * @package s2Member\Utilities
2129 * @since 260903.1918
2130 *
2131 * @return null
2132 */
2133 protected static function reset_static_asset_builds()
2134 {
2135 delete_option('ws_plugin__s2member_static_asset_builds');
2136 delete_option('ws_plugin__s2member_static_asset_data_map_signatures'); //260906.1530 Static JavaScript and its data-map slot layout must stay synchronized.
2137 delete_option('ws_plugin__s2member_static_asset_health');
2138 self::$static_asset_cache = array();
2139 self::$static_js_data_map_cache = array();
2140 self::$static_assets_health_cache = NULL;
2141 foreach(array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js') as $id)
2142 delete_transient('ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id));
2143 return;
2144 }
2145
2146 /**
2147 * Invalidates selected generated frontend assets.
2148 *
2149 * Selectors may be `css`, `js`, `framework_css`, `framework_js`, `pro_css`, `pro_js`, or exact logical generated filenames.
2150 * Existing files remain available for already-cached HTML. Current pages stop referencing a stale generation until its replacement succeeds.
2151 *
2152 * @package s2Member\Utilities
2153 * @since 260903.0437
2154 *
2155 * @param array|string $assets Asset selectors.
2156 * @return null
2157 */
2158 public static function invalidate_static_assets($assets = array('css', 'js'))
2159 {
2160 $assets = is_array($assets) ? $assets : array($assets);
2161 $ids = array();
2162 foreach($assets as $asset)
2163 {
2164 $asset = strtolower((string)$asset);
2165 if(in_array($asset, array('css', 'js'), TRUE))
2166 $ids = array_merge($ids, self::static_asset_ids($asset, 'all'));
2167 else if(preg_match('/^(framework|pro)_(css|js)$/', $asset, $match))
2168 $ids = array_merge($ids, self::static_asset_ids($match[2], $match[1]));
2169 else if(in_array($asset, array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js'), TRUE))
2170 $ids[] = $asset;
2171 }
2172 foreach(array_unique($ids) as $id)
2173 {
2174 $build = self::static_asset_build($id);
2175 //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.
2176 if($build)
2177 self::set_static_asset_build($id, -abs($build));
2178 else
2179 unset(self::$static_asset_cache[$id]);
2180 delete_transient('ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id));
2181 }
2182 return;
2183 }
2184
2185 /**
2186 * Invalidates generated assets when s2Member or WordPress values rendered into them change.
2187 *
2188 * @package s2Member\Utilities
2189 * @since 260903.0437
2190 *
2191 * @param string $option Option name.
2192 * @param mixed $old_value Previous value.
2193 * @param mixed $value New value.
2194 * @return null
2195 */
2196 public static function maybe_invalidate_after_wp_option_update($option = '', $old_value = NULL, $value = NULL)
2197 {
2198 if($old_value === $value)
2199 return;
2200
2201 if((string)$option === 'ws_plugin__s2member_options')
2202 {
2203 if(!is_array($old_value) || !is_array($value))
2204 return;
2205 $old = (array)$old_value;
2206 $new = (array)$value;
2207
2208 //260907.2203 Keep a concise operational history of CSS/JS configuration changes when s2Member logging is enabled.
2209 $config_changes = array();
2210 //260912.0522 Include wait-time changes because they can explain a sudden change in Late asset loads even when delivery settings themselves did not change.
2211 foreach(array('dynamic_asset_loader', 'static_css', 'static_css_minify', 'static_js', 'static_js_text', 'static_js_minify', 'static_assets_combine', 'asset_health_wait_seconds') as $key)
2212 if(serialize(isset($old[$key]) ? $old[$key] : NULL) !== serialize(isset($new[$key]) ? $new[$key] : NULL))
2213 $config_changes[$key] = array('old' => isset($old[$key]) ? $old[$key] : NULL, 'new' => isset($new[$key]) ? $new[$key] : NULL);
2214 if($config_changes)
2215 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'CSS/JS configuration changed', 'changes' => $config_changes));
2216
2217 if((string)(isset($old['static_assets_combine']) ? $old['static_assets_combine'] : '0') !== (string)(isset($new['static_assets_combine']) ? $new['static_assets_combine'] : '0'))
2218 {
2219 //260911.1834 A combine-mode change resets both representations; queue enabled CSS/JS for immediate rebuilding after the complete new option set has finished saving.
2220 self::$static_assets_rebuild_after_save = array('css', 'js');
2221 self::reset_static_asset_builds();
2222 return;
2223 }
2224
2225 $keys = array(
2226 'css' => array('static_css', 'static_css_minify'),
2227 'js' => array('static_js', 'static_js_text', 'static_js_minify'),
2228 'framework_js' => array('custom_reg_force_personal_emails', 'custom_reg_password_min_length', 'custom_reg_password_min_strength'),
2229 'pro_css' => array('pro_gateways_enabled'),
2230 'pro_js' => array(
2231 'pro_gateways_enabled', 'pro_stripe_api_publishable_key', 'pro_stripe_api_image', 'pro_stripe_api_allow_remember_me',
2232 'paypal_checkout_enable', 'paypal_checkout_sandbox', 'paypal_checkout_client_id', 'paypal_checkout_sandbox_client_id', 'sec_encryption_key',
2233 ),
2234 );
2235 $keys = (array)apply_filters('ws_plugin__s2member_static_asset_option_keys', $keys, get_defined_vars());
2236 $invalidate = array();
2237 foreach($keys as $selector => $option_keys)
2238 foreach((array)$option_keys as $key)
2239 if((isset($old[$key]) || isset($new[$key])) && serialize(isset($old[$key]) ? $old[$key] : NULL) !== serialize(isset($new[$key]) ? $new[$key] : NULL))
2240 {
2241 $invalidate[] = $selector;
2242 break;
2243 }
2244 if($invalidate)
2245 {
2246 //260911.1834 Remember only the affected types until update_all_options has finished; rebuilding here could still see the request's old global option set.
2247 foreach($invalidate as $_static_asset_selector)
2248 foreach(array('css', 'js') as $_static_asset_type)
2249 if(strpos((string)$_static_asset_selector, $_static_asset_type) !== FALSE)
2250 self::$static_assets_rebuild_after_save[] = $_static_asset_type;
2251 self::$static_assets_rebuild_after_save = array_values(array_unique(self::$static_assets_rebuild_after_save));
2252 self::invalidate_static_assets($invalidate);
2253 }
2254 return;
2255 }
2256 if(in_array((string)$option, array('siteurl', 'home'), TRUE))
2257 self::invalidate_static_assets(array('css', 'js'));
2258 else if((string)$option === 'WPLANG' && self::static_js_text_delivery() !== 'page')
2259 self::invalidate_static_assets('js');
2260 return;
2261 }
2262
2263 /**
2264 * Rebuilds enabled static asset types after s2Member finishes saving relevant options.
2265 *
2266 * @package s2Member\Utilities
2267 * @since 260911.1834
2268 *
2269 * @param array $vars Variables passed by ws_plugin__s2member_after_update_all_options.
2270 * @return null
2271 */
2272 public static function rebuild_static_assets_after_options_save($vars = array())
2273 {
2274 if(empty($vars['updated_all_options']) || !self::$static_assets_rebuild_after_save)
2275 return;
2276
2277 $types = array_values(array_unique(self::$static_assets_rebuild_after_save));
2278 self::$static_assets_rebuild_after_save = array();
2279 $options = (!empty($vars['options']) && is_array($vars['options'])) ? $vars['options'] : get_option('ws_plugin__s2member_options', array());
2280 if(!is_array($options))
2281 return;
2282
2283 //260911.1834 Build from the complete newly saved option set; lazy frontend generation remains the recovery path for later upgrades, deletions, or transient failures.
2284 $previous_options = $GLOBALS['WS_PLUGIN__']['s2member']['o'];
2285 $GLOBALS['WS_PLUGIN__']['s2member']['o'] = $options;
2286 $results = array();
2287 foreach($types as $type)
2288 if(in_array($type, array('css', 'js'), TRUE) && !empty($options['static_'.$type]))
2289 $results[$type] = self::ensure_static_assets($type);
2290 $GLOBALS['WS_PLUGIN__']['s2member']['o'] = $previous_options;
2291
2292 if($results)
2293 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'Static CSS/JS rebuilt after option save', 'result' => 'completed', 'types' => array_keys($results)));
2294 return;
2295 }
2296
2297 /**
2298 * Rebuilds enabled static assets opportunistically on normal privileged administrator page-loads.
2299 *
2300 * Routine requests only perform a few build-state/filesystem checks. Actual generation runs only when an active asset is pending, has never been generated, or its current local file is missing.
2301 *
2302 * @package s2Member\Utilities
2303 * @since 260911.1924
2304 *
2305 * @return null
2306 */
2307 public static function maybe_rebuild_static_assets_on_admin_request()
2308 {
2309 if(!is_admin() || !current_user_can('create_users') || (defined('DOING_AJAX') && DOING_AJAX))
2310 return;
2311 if(empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_css']) && empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_js']))
2312 return;
2313
2314 $location = self::static_assets_location(FALSE);
2315 foreach(array('css' => 'static_css', 'js' => 'static_js') as $type => $option)
2316 {
2317 if(empty($GLOBALS['WS_PLUGIN__']['s2member']['o'][$option]))
2318 continue;
2319 $needs_rebuild = FALSE;
2320 foreach(self::static_asset_ids($type, 'all') as $id)
2321 {
2322 $build = self::static_asset_build($id);
2323 if($build <= 0)
2324 {
2325 $needs_rebuild = TRUE;
2326 continue;
2327 }
2328 if(!empty($location['ok']))
2329 {
2330 $base = substr($id, 0, -strlen('.'.$type));
2331 if(!is_file($location['dir'].'/'.$base.'-'.$build.'.'.$type))
2332 {
2333 $needs_rebuild = TRUE;
2334 }
2335 }
2336 }
2337 //260911.2325 Generation/failure cooldown, missing-file repair locking, and repaired-issue history are centralized in ensure_static_asset(); avoid a second history write from the admin recovery wrapper.
2338 if($needs_rebuild)
2339 self::ensure_static_assets($type);
2340 }
2341 return;
2342 }
2343
2344 /**
2345 * Invalidates generated frontend assets when plugin activation/deactivation can change frontend integrations.
2346 *
2347 * @package s2Member\Utilities
2348 * @since 260903.0437
2349 *
2350 * @return null
2351 */
2352 public static function invalidate_after_plugin_change($plugin = '')
2353 {
2354 $plugin = (string)$plugin;
2355 if($plugin === 's2member-pro/s2member-pro.php')
2356 self::reset_static_asset_builds();
2357 else if($plugin === 'buddypress/bp-loader.php')
2358 self::invalidate_static_assets('framework_js');
2359 return;
2360 }
2361
2362 /**
2363 * Invalidates generated frontend assets after plugin/translation upgrades.
2364 *
2365 * @package s2Member\Utilities
2366 * @since 260903.0437
2367 *
2368 * @param object $upgrader WordPress upgrader instance.
2369 * @param array $options Upgrade details.
2370 * @return null
2371 */
2372 public static function maybe_invalidate_after_upgrade($upgrader = NULL, $options = array())
2373 {
2374 if(!is_array($options) || empty($options['type']))
2375 return;
2376 if($options['type'] === 'translation')
2377 {
2378 if(self::static_js_text_delivery() !== 'page')
2379 self::invalidate_static_assets('js'); //260906.2049 Page-loaded JavaScript text follows the current translation without rebuilding the external static file.
2380 return;
2381 }
2382 if($options['type'] !== 'plugin')
2383 return;
2384
2385 $plugins = array();
2386 if(!empty($options['plugin']))
2387 $plugins[] = (string)$options['plugin'];
2388 if(!empty($options['plugins']) && is_array($options['plugins']))
2389 $plugins = array_merge($plugins, $options['plugins']);
2390 foreach(array_unique($plugins) as $plugin)
2391 {
2392 if($plugin === 's2member/s2member.php')
2393 self::invalidate_static_assets(array('framework_css', 'framework_js'));
2394 else if($plugin === 's2member-pro/s2member-pro.php')
2395 self::invalidate_static_assets(array('pro_css', 'pro_js'));
2396 else if($plugin === 'buddypress/bp-loader.php')
2397 self::invalidate_static_assets('framework_js');
2398 }
2399 return;
2400 }
2401
2402 /**
2403 * Deletes a static-asset repair lock only when the stored value still belongs to the expected owner.
2404 *
2405 * @package s2Member\Utilities
2406 * @since 260913.0704
2407 *
2408 * @param string $option Repair-lock option name.
2409 * @param string $lock Expected lock-owner value.
2410 * @return bool True when this exact lock was deleted.
2411 */
2412 protected static function static_asset_repair_lock_delete($option = '', $lock = '')
2413 {
2414 global $wpdb;
2415
2416 $option = (string)$option;
2417 $lock = (string)$lock;
2418 if($option === '' || $lock === '')
2419 return FALSE;
2420
2421 //260913.0704 Delete only the lock version this request observed or acquired; another request may have replaced it in the meantime.
2422 $deleted = $wpdb->delete($wpdb->options, array('option_name' => $option, 'option_value' => maybe_serialize($lock)), array('%s', '%s'));
2423 if($deleted)
2424 wp_cache_delete($option, 'options');
2425 return (bool)$deleted;
2426 }
2427
2428 /**
2429 * Returns one current generated frontend asset URL, building it when stale/uninitialized.
2430 *
2431 * Active timestamped files are existence-checked before their URLs are emitted. A missing or
2432 * trusted-browser-confirmed unreachable file returns a failure so callers can use dynamic delivery immediately.
2433 *
2434 * @package s2Member\Utilities
2435 * @since 260903.0525
2436 *
2437 * @param string $id Logical generated filename.
2438 * @param bool $force Force a new build timestamp immediately.
2439 * @return array Result with `ok`, `url`, `build`, and `error` keys.
2440 */
2441 public static function ensure_static_asset($id = '', $force = FALSE)
2442 {
2443 $id = strtolower((string)$id);
2444 if(in_array($id, array('css', 'js'), TRUE))
2445 $id = 's2member.'.$id;
2446 if(!in_array($id, array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js'), TRUE))
2447 return array('ok' => FALSE, 'url' => '', 'build' => 0, 'error' => 'Invalid static asset ID');
2448 $type = substr(strrchr($id, '.'), 1);
2449 if(!in_array($id, self::static_asset_ids($type, 'all'), TRUE))
2450 return array('ok' => FALSE, 'url' => '', 'build' => 0, 'error' => 'Static asset is not active in the current delivery mode');
2451 if(!$force && isset(self::$static_asset_cache[$id]))
2452 return self::$static_asset_cache[$id];
2453
2454 //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.
2455 $compatibility = self::static_asset_definition($id, FALSE);
2456 if(empty($compatibility['ok']))
2457 return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => abs(self::static_asset_build($id)), 'error' => (string)$compatibility['error'], 'dynamic_required' => !empty($compatibility['dynamic_required']));
2458
2459 $state = self::static_asset_build($id);
2460 $dirty = $state < 0;
2461 $active_build = abs($state);
2462 $base = substr($id, 0, -strlen('.'.$type));
2463 $failure_key = 'ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id);
2464 $repair_lock_key = '';
2465 $repair_lock_value = '';
2466 $data_map_signature = array('ok' => TRUE, 'signature' => '', 'error' => '');
2467 $uses_data_map = $type === 'js' && self::static_js_text_delivery() === 'page';
2468 if($uses_data_map)
2469 {
2470 $data_map_signature = self::static_js_data_map_signature($id);
2471 if(empty($data_map_signature['ok']))
2472 return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => (string)$data_map_signature['error']);
2473 //260906.1530 Regenerate static JavaScript when its shipped data-map layout changes so slot numbers stay synchronized.
2474 if($active_build && self::static_asset_data_map_signature($id) !== (string)$data_map_signature['signature'])
2475 {
2476 $dirty = TRUE;
2477 $state = -$active_build;
2478 self::set_static_asset_build($id, $state);
2479 }
2480 }
2481 if(!$force && !$dirty && $active_build)
2482 {
2483 $location = self::static_assets_location(FALSE);
2484 if(empty($location['ok']))
2485 return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => $location['error']);
2486 $url = $location['url'].'/'.$base.'-'.$active_build.'.'.$type;
2487 $path = $location['dir'].'/'.$base.'-'.$active_build.'.'.$type;
2488 if(!is_file($path))
2489 {
2490 //260911.1806 A configured static file that vanished locally is not an admin preference: try one guarded synchronous repair, then let normal dynamic fallback handle this request if repair cannot complete.
2491 if($failure = get_transient($failure_key))
2492 return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => (string)$failure);
2493 $repair_lock_key = 'ws_plugin__s2member_static_asset_repair_lock_'.str_replace('.', '_', $id);
2494 $repair_lock_current = (string)get_option($repair_lock_key, '');
2495 $repair_lock_parts = explode(':', $repair_lock_current, 2);
2496 $repair_lock_time = (!empty($repair_lock_parts[0]) && is_numeric($repair_lock_parts[0])) ? (int)$repair_lock_parts[0] : 0;
2497 //260913.0704 Preserve compatibility with older timestamp-only locks while making stale takeover conditional on the exact lock value this request inspected.
2498 if($repair_lock_current !== '' && (!$repair_lock_time || $repair_lock_time < time() - 30))
2499 self::static_asset_repair_lock_delete($repair_lock_key, $repair_lock_current);
2500 $repair_lock_value = time().':'.sha1(microtime(TRUE)."\0".wp_rand());
2501 if(!add_option($repair_lock_key, $repair_lock_value, '', 'no'))
2502 return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => 'Expected static asset '.$id.' is missing; another request is already rebuilding it.');
2503 $dirty = TRUE;
2504 }
2505 else
2506 {
2507 //260904.2110 A local file check is cheaper than sending a broken static URL; browser-confirmed public-URL failures still fall back without rebuilding a valid local file.
2508 if(self::asset_http_target_failed('static:'.$id, $url))
2509 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.');
2510 return self::$static_asset_cache[$id] = array('ok' => TRUE, 'url' => $url, 'build' => $active_build, 'error' => '');
2511 }
2512 }
2513
2514 if(!$force && $dirty && ($failure = get_transient($failure_key)))
2515 return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => (string)$failure);
2516
2517 $definition = self::static_asset_definition($id, TRUE);
2518 if(empty($definition['ok']))
2519 {
2520 if($repair_lock_key !== '')
2521 {
2522 set_transient($failure_key, (string)$definition['error'], 5 * MINUTE_IN_SECONDS);
2523 self::static_asset_repair_lock_delete($repair_lock_key, $repair_lock_value);
2524 }
2525 return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => (string)$definition['error']);
2526 }
2527
2528 $build = max(time(), $active_build + 1);
2529 $result = self::build_static_asset($base, $build, $type, $definition['sources'], !empty($definition['minify']));
2530 if(!empty($result['ok']))
2531 {
2532 if($uses_data_map)
2533 self::set_static_asset_data_map_signature($id, (string)$data_map_signature['signature']);
2534 self::set_static_asset_build($id, $build);
2535
2536 //260907.2203 Record successful generation so automatic and manual rebuilds remain visible later.
2537 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array(
2538 'event' => 'Static CSS/JS asset generated', 'result' => 'success', 'asset' => $id, 'build' => $build,
2539 'trigger' => $force ? 'forced refresh' : 'automatic generation', 'minified' => !empty($definition['minify']), 'url' => $result['url'],
2540 ));
2541
2542 //260905.0106 Prune only after the new timestamp is current so the previous generation is treated as stale instead of protected.
2543 self::prune_static_asset_generations(dirname($result['path']), $result['path']);
2544 delete_transient($failure_key);
2545 if($repair_lock_key !== '')
2546 {
2547 //260911.1834 A missing active file that repaired successfully is still useful history, but it must not lower the health score because this request retained static delivery.
2548 $health_id = self::asset_runtime_health_id($id, $type, 'static');
2549 self::queue_asset_health_issue_snapshot('repaired', self::asset_runtime_health_label($health_id), 'Expected static asset '.$id.' was missing and was rebuilt automatically.');
2550 self::static_asset_repair_lock_delete($repair_lock_key, $repair_lock_value);
2551 }
2552 return self::$static_asset_cache[$id] = array('ok' => TRUE, 'url' => $result['url'], 'build' => $build, 'error' => '');
2553 }
2554 set_transient($failure_key, (string)$result['error'], 5 * MINUTE_IN_SECONDS);
2555 if($repair_lock_key !== '')
2556 self::static_asset_repair_lock_delete($repair_lock_key, $repair_lock_value);
2557
2558 //260907.2203 Preserve failed generation details even when delivery later falls back or recovers automatically.
2559 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array(
2560 'event' => 'Static CSS/JS asset generation failed', 'result' => 'failure', 'asset' => $id, 'attempted_build' => $build,
2561 'previous_build' => $active_build, 'trigger' => $force ? 'forced refresh' : 'automatic generation', 'error' => (string)$result['error'],
2562 ));
2563
2564 return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => $result['error']);
2565 }
2566
2567 /**
2568 * Returns all active generated frontend assets for one type.
2569 *
2570 * 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.
2571 *
2572 * @package s2Member\Utilities
2573 * @since 260903.1918
2574 *
2575 * @param string $type `css` or `js`.
2576 * @param bool $force Force fresh timestamps for every active file of this type.
2577 * @return array Aggregate result with individual assets keyed by logical filename.
2578 */
2579 public static function ensure_static_assets($type = '', $force = FALSE)
2580 {
2581 $type = strtolower((string)$type);
2582 if(!in_array($type, array('css', 'js'), TRUE))
2583 return array('ok' => FALSE, 'assets' => array(), 'error' => 'Invalid static asset type');
2584 $option = 'static_'.$type;
2585 if(empty($GLOBALS['WS_PLUGIN__']['s2member']['o'][$option]))
2586 return array('ok' => FALSE, 'assets' => array(), 'error' => 'Static '.strtoupper($type).' Delivery is disabled');
2587
2588 $assets = array();
2589 foreach(self::static_asset_ids($type, 'all') as $id)
2590 {
2591 $assets[$id] = self::ensure_static_asset($id, $force);
2592 if(empty($assets[$id]['ok']))
2593 return array('ok' => FALSE, 'assets' => $assets, 'error' => (string)$assets[$id]['error'], 'dynamic_required' => !empty($assets[$id]['dynamic_required']));
2594 }
2595 return array('ok' => (bool)$assets, 'assets' => $assets, 'error' => '');
2596 }
2597
2598 /**
2599 * Refreshes all currently enabled static frontend asset types immediately.
2600 *
2601 * CSS and JS remain independent; within each type only files active in the current separate/combined representation are rebuilt.
2602 *
2603 * @package s2Member\Utilities
2604 * @since 260903.0525
2605 *
2606 * @return array Results keyed by `css` and/or `js`.
2607 */
2608 public static function refresh_static_assets()
2609 {
2610 $results = array();
2611 foreach(array('css' => 'static_css', 'js' => 'static_js') as $type => $option)
2612 if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o'][$option]))
2613 {
2614 foreach(self::static_asset_ids($type, 'all') as $id)
2615 {
2616 unset(self::$static_asset_cache[$id]);
2617 delete_transient('ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id));
2618 }
2619 $results[$type] = self::ensure_static_assets($type, TRUE);
2620 }
2621 return $results;
2622 }
2623
2624 /**
2625 * AJAX handler for the General Options “Refresh Static Assets” button.
2626 *
2627 * @package s2Member\Utilities
2628 * @since 260903.0437
2629 *
2630 * @return null Exits through WordPress JSON helpers.
2631 */
2632 public static function ajax_refresh_static_assets()
2633 {
2634 check_ajax_referer('ws-plugin--s2member-refresh-static-assets');
2635 if(!current_user_can('create_users'))
2636 wp_send_json_error(array('message' => 'You do not have permission to refresh s2Member static assets.'), 403);
2637
2638 $results = self::refresh_static_assets();
2639 if(!$results)
2640 wp_send_json_error(array('message' => 'Enable Static CSS Delivery or Static JS Delivery and save the options first.'), 400);
2641
2642 $success = $errors = array();
2643 foreach($results as $type => $result)
2644 if(!empty($result['ok']))
2645 $success[] = strtoupper($type);
2646 else
2647 $errors[] = strtoupper($type).': '.((!empty($result['error'])) ? $result['error'] : 'unknown build error');
2648
2649 if(!$errors)
2650 {
2651 //260907.2203 Record the administrator-triggered refresh result.
2652 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'Manual Static CSS/JS refresh', 'result' => 'success', 'refreshed' => $success));
2653
2654 wp_send_json_success(array('message' => 'Static '.implode(' + ', $success).' refreshed. New timestamped files are active.'));
2655 }
2656
2657 //260907.2203 Record partial and failed administrator-triggered refreshes too.
2658 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));
2659
2660 $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.';
2661 wp_send_json_error(array('message' => $message), 500);
2662 }
2663
2664 /**
2665 * Checks the few currently active generated files for local filesystem availability.
2666 *
2667 * 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.
2668 *
2669 * @package s2Member\Utilities
2670 * @since 260903.0525
2671 *
2672 * @param bool $force Recheck even if this request already has a cached result.
2673 * @return array Missing active asset files keyed by logical filename.
2674 */
2675 public static function static_assets_health($force = FALSE)
2676 {
2677 if(!$force && isset(self::$static_assets_health_cache))
2678 return self::$static_assets_health_cache;
2679 if(empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_css']) && empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_js']))
2680 return self::$static_assets_health_cache = array();
2681
2682 $missing = array();
2683 $checked_types = array();
2684 foreach(array('css' => 'static_css', 'js' => 'static_js') as $type => $option)
2685 if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o'][$option]))
2686 {
2687 $dynamic_requirement = self::static_type_dynamic_requirement($type);
2688 if(empty($dynamic_requirement['required']))
2689 $checked_types[] = $type; //260913.2001 Only currently active static routes participate in local static-file health.
2690 }
2691 $location = self::static_assets_location(FALSE);
2692 if($checked_types && empty($location['ok']))
2693 $missing['location'] = $location['error'];
2694 else if(!empty($location['ok']))
2695 foreach($checked_types as $type)
2696 foreach(self::static_asset_ids($type, 'all') as $id)
2697 {
2698 $build = self::static_asset_build($id);
2699 $base = substr($id, 0, -strlen('.'.$type));
2700 if($build > 0 && !is_file($location['dir'].'/'.$base.'-'.$build.'.'.$type))
2701 $missing[$id] = 'Expected static asset '.$id.' is missing.';
2702 }
2703
2704 //260907.2203 Log only local-health transitions so recurring admin checks do not repeat the same event.
2705 $previous = get_option('ws_plugin__s2member_static_asset_health', array());
2706 $previous = is_array($previous) ? $previous : array();
2707 if(serialize($previous) !== serialize($missing))
2708 {
2709 update_option('ws_plugin__s2member_static_asset_health', $missing, FALSE);
2710 if($missing)
2711 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'Static CSS/JS local health issue', 'result' => 'failure', 'issues' => $missing));
2712 else if($previous)
2713 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'Static CSS/JS local health recovered', 'result' => 'recovered', 'previous_issues' => $previous));
2714 }
2715
2716 return self::$static_assets_health_cache = $missing;
2717 }
2718
2719 /**
2720 * Returns timing status for one physical frontend CSS/JavaScript response.
2721 *
2722 * A pending real-page activation delay is Late/Yellow until the trusted browser check decides
2723 * whether the response is valid. Confirmed historical timing evidence remains in Health scoring
2724 * and Latest Issues instead of making a recovered asset row look currently unhealthy.
2725 *
2726 * @package s2Member\Utilities
2727 * @since 260909.2021
2728 *
2729 * @param string $id Runtime-health ID.
2730 * @return array Status details.
2731 */
2732 protected static function asset_runtime_health_event($id = '')
2733 {
2734 $id = (string)$id;
2735 $latest = 0;
2736 $pending = FALSE;
2737
2738 //260913.0059 Current rows describe current known state only; a trusted-successful follow-up leaves the Late event in scoring/Latest Issues instead of holding this row Yellow for an hour.
2739 foreach(self::asset_runtime_suspicions() as $suspicion)
2740 if(!empty($suspicion['id']) && (string)$suspicion['id'] === $id)
2741 {
2742 $pending = TRUE;
2743 $latest = max($latest, (int)$suspicion['reported']);
2744 }
2745
2746 if($pending)
2747 return array(
2748 'status' => 'delayed',
2749 'label' => 'Late',
2750 'detail' => 'A frontend page could not confirm that this asset became active within the configured wait time. A trusted browser check will verify the asset response.',
2751 'reported' => $latest,
2752 );
2753 return array('status' => 'healthy', 'label' => 'Healthy', 'detail' => '', 'reported' => 0);
2754 }
2755
2756 /**
2757 * Returns consolidated site-owner health for active frontend CSS/JavaScript delivery.
2758 *
2759 * Current rows explain the actual configured/preferred route and any fallback. The
2760 * headline color comes from the Okay/Late/Fallback/Failed asset-load score: the latest 10
2761 * individual loads plus populated clock minutes from the latest 10 minutes, with newer evidence more important.
2762 *
2763 * @package s2Member\Utilities
2764 * @since 260909.2021
2765 *
2766 * @param bool $force Recheck local static-file health and request a full trusted browser probe on this admin page.
2767 * @return array Overall status, score details, rows, notice level/items, and notice signature.
2768 */
2769 public static function frontend_asset_health($force = FALSE)
2770 {
2771 if($force)
2772 self::$asset_health_force_full_probe = TRUE; //260912.0522 Opening the Health panel asks the footer probe for full current-route activation checks, not only the cheap background reachability checks.
2773
2774 //260912.0258 Run the Health Logkeeper before rendering admin health so queued frontend evidence is reflected without requiring another refresh.
2775 self::run_health_logkeeper();
2776 $rows = array();
2777 $error_notice_items = array();
2778 $attention_items = array();
2779 //260910.0709 Rows describe the actual route in use; notice item lists are separate so Yellow/Orange status can remain informative without automatically becoming an admin-wide alarm.
2780 $http_health = self::asset_http_health_state();
2781 $failures = (is_array($http_health) && !empty($http_health['failures']) && is_array($http_health['failures'])) ? $http_health['failures'] : array();
2782 $runtime_warnings = (is_array($http_health) && !empty($http_health['runtime_warnings']) && is_array($http_health['runtime_warnings'])) ? $http_health['runtime_warnings'] : array();
2783 $local_health = self::static_assets_health($force);
2784 $location = self::static_assets_location(FALSE);
2785 $selected_s2o = empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress';
2786 $dynamic_normal = array('css' => FALSE, 'js' => FALSE);
2787 $wp_loader_active = array('css' => FALSE, 'js' => FALSE);
2788 $wp_loader_fallback = array('css' => FALSE, 'js' => FALSE);
2789 $wp_loader_required = array('css' => FALSE, 'js' => FALSE);
2790
2791 foreach(array('css' => 'CSS', 'js' => 'JS') as $type => $type_label)
2792 {
2793 $static_requested = !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_'.$type]);
2794 if(!$static_requested)
2795 {
2796 $dynamic_normal[$type] = TRUE;
2797 $using_wordpress = self::dynamic_asset_url(FALSE) === self::wordpress_dynamic_asset_url();
2798 $wp_loader_active[$type] = $using_wordpress;
2799 $wp_loader_fallback[$type] = $selected_s2o;
2800 if($selected_s2o)
2801 {
2802 //260912.1956 Show the configured s2Member-Only route separately from its WordPress fallback so each route's current health is understandable at a glance.
2803 $s2o_url = ($type === 'css')
2804 ? add_query_arg(array('ws_plugin__s2member_css' => '1', 'qcABC' => '1'), $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'])
2805 : add_query_arg(array('ws_plugin__s2member_js_w_globals' => (defined('WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5') ? WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5 : '1'), 'qcABC' => '1'), $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url']);
2806 $event = self::asset_runtime_health_event('dynamic_'.$type);
2807 if($using_wordpress)
2808 {
2809 $status = 'error';
2810 $status_label = 'Failed';
2811 $detail = 'The selected s2Member-Only Dynamic Loader could not be used; Full WordPress Dynamic fallback is serving this asset.';
2812 $attention_items['fallback:'.$type] = $type_label.' is using Full WordPress Dynamic Loader because the selected s2Member-Only Dynamic Loader could not be used.';
2813 }
2814 else
2815 {
2816 $status = $event['status'];
2817 $status_label = $event['label'];
2818 $detail = 's2Member-Only Dynamic Loader.'.(($event['detail']) ? ' '.$event['detail'] : '');
2819 }
2820 $rows[] = array('label' => 's2Member-Only '.$type_label, 'delivery' => 'Dynamic', 'status' => $status, 'status_label' => $status_label, 'detail' => $detail, 'url' => $s2o_url);
2821 }
2822 continue;
2823 }
2824
2825 $ids = self::static_asset_ids($type, 'all');
2826 $dynamic_requirement = self::static_type_dynamic_requirement($type);
2827 if(!empty($dynamic_requirement['required']))
2828 {
2829 //260913.2001 A compatibility-required Dynamic route is expected delivery, not an Orange fallback; Full WordPress remains mandatory so the triggering hooks/configuration are present.
2830 $wp_loader_active[$type] = TRUE;
2831 $wp_loader_fallback[$type] = FALSE;
2832 $wp_loader_required[$type] = TRUE;
2833 $delivery_url = self::wordpress_dynamic_asset_url();
2834 $delivery_url = ($type === 'css') ? add_query_arg(array('ws_plugin__s2member_css' => '1', 'qcABC' => '1'), $delivery_url) : add_query_arg(array('ws_plugin__s2member_js_w_globals' => (defined('WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5') ? WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5 : '1'), 'qcABC' => '1'), $delivery_url);
2835 $failed = !empty($failures['dynamic:dynamic_'.$type]);
2836 $status = ($failed) ? 'error' : 'healthy';
2837 $status_label = ($failed) ? 'Delivery check failed' : 'Healthy';
2838 //260913.2111 The requirement detail now carries concise, actionable guidance; do not append a generic compatibility sentence to every row.
2839 $detail = (string)$dynamic_requirement['detail'];
2840 if($failed)
2841 {
2842 $detail .= ' The required Full WordPress Dynamic Loader could not be loaded or confirmed active.';
2843 $error_notice_items['delivery:'.$type] = $type_label.' requires Full WordPress Dynamic delivery, but that route could not be loaded or verified.';
2844 }
2845 $rows[] = array('label' => $type_label.' Delivery', 'delivery' => 'Dynamic required', 'status' => $status, 'status_label' => $status_label, 'detail' => $detail, 'url' => $delivery_url);
2846 continue;
2847 }
2848
2849 $fallback = FALSE;
2850 $fallback_reasons = array();
2851 $states = array();
2852 $generation_failures = array();
2853
2854 //260910.0630 A compatibility/build fallback applies to the whole asset type; stale failures for static files that are no longer being served must not masquerade as current delivery failures.
2855 foreach($ids as $id)
2856 {
2857 $states[$id] = self::static_asset_build($id);
2858 $generation_failures[$id] = get_transient('ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id));
2859 $definition = self::static_asset_definition($id, FALSE);
2860 if(empty($definition['ok']))
2861 {
2862 $fallback = TRUE;
2863 $fallback_reasons[] = (string)$definition['error'];
2864 }
2865 else if($generation_failures[$id] && $states[$id] <= 0)
2866 {
2867 $fallback = TRUE;
2868 $fallback_reasons[] = $id.': '.(string)$generation_failures[$id];
2869 }
2870 }
2871
2872 if(!$fallback)
2873 foreach($ids as $id)
2874 {
2875 $state = $states[$id];
2876 $build = abs($state);
2877 if(!empty($local_health['location']))
2878 {
2879 $fallback = TRUE;
2880 $fallback_reasons[] = (string)$local_health['location'];
2881 continue;
2882 }
2883 if(isset($local_health[$id]))
2884 {
2885 $fallback = TRUE;
2886 $fallback_reasons[] = (string)$local_health[$id];
2887 continue;
2888 }
2889 if($state > 0)
2890 {
2891 $base = substr($id, 0, -strlen('.'.$type));
2892 $url = (!empty($location['ok'])) ? $location['url'].'/'.$base.'-'.$build.'.'.$type : '';
2893 if($url && self::asset_http_target_failed('static:'.$id, $url))
2894 {
2895 $fallback = TRUE;
2896 $fallback_reasons[] = $id.' could not be loaded from its public URL.';
2897 }
2898 }
2899 }
2900
2901 if($fallback)
2902 {
2903 $wp_loader_active[$type] = TRUE;
2904 $wp_loader_fallback[$type] = TRUE;
2905 $status = 'attention';
2906 $status_label = 'Using dynamic fallback';
2907 $detail = 'Full WordPress Dynamic Loader is being used instead of the requested static '.$type_label.' delivery.';
2908 if($fallback_reasons)
2909 $detail .= ' '.implode(' ', array_unique($fallback_reasons));
2910 $attention_items['fallback:'.$type] = 'Requested static '.$type_label.' delivery is unavailable; Full WordPress Dynamic Loader is being used instead.';
2911 $delivery_url = self::wordpress_dynamic_asset_url();
2912 $delivery_url = ($type === 'css') ? add_query_arg(array('ws_plugin__s2member_css' => '1', 'qcABC' => '1'), $delivery_url) : add_query_arg(array('ws_plugin__s2member_js_w_globals' => (defined('WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5') ? WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5 : '1'), 'qcABC' => '1'), $delivery_url);
2913 $event = self::asset_runtime_health_event('dynamic_'.$type);
2914 if($event['detail'])
2915 $detail .= ' '.$event['detail'];
2916 if(!empty($failures['fallback:dynamic_'.$type]) || !empty($failures['dynamic:dynamic_'.$type]))
2917 {
2918 $status = 'error';
2919 $status_label = 'Fallback check failed';
2920 $detail = 'The requested static '.$type_label.' delivery is unavailable, and the Full WordPress Dynamic fallback could not be loaded or verified.';
2921 $error_notice_items['delivery:'.$type] = $type_label.' preferred delivery is unavailable and the Full WordPress Dynamic fallback could not be loaded or verified.';
2922 }
2923 $rows[] = array('label' => $type_label.' Delivery', 'delivery' => 'Dynamic fallback', 'status' => $status, 'status_label' => $status_label, 'detail' => $detail, 'url' => $delivery_url);
2924 continue;
2925 }
2926
2927 $wp_loader_fallback[$type] = TRUE;
2928 foreach($ids as $id)
2929 {
2930 $state = self::static_asset_build($id);
2931 $build = abs($state);
2932 $failure = get_transient('ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id));
2933 $health_id = self::asset_runtime_health_id($id, $type, 'static');
2934 $event = self::asset_runtime_health_event($health_id);
2935 $status = $event['status'];
2936 $status_label = $event['label'];
2937 if($state < 0)
2938 {
2939 $status = ($status === 'healthy') ? 'delayed' : $status;
2940 $status_label = ($status === 'delayed' && $event['status'] === 'healthy') ? 'Pending rebuild' : $status_label;
2941 $detail = 'Static file is pending rebuild; its previous timestamp remains only for already-cached HTML.';
2942 }
2943 else
2944 $detail = ($build > 0) ? 'Static file is current (build '.date_i18n('Y-m-d H:i:s', $build).').' : 'Static file has not been created yet; s2Member will build it automatically.';
2945 if($failure && $state > 0)
2946 {
2947 $status = ($status === 'healthy') ? 'delayed' : $status;
2948 $status_label = ($status === 'delayed' && $event['status'] === 'healthy') ? 'Rebuild issue' : $status_label;
2949 $detail .= ' A recent rebuild failed, but the previous valid static file remains active. '.(string)$failure;
2950 }
2951 if($event['detail'])
2952 $detail .= ' '.$event['detail'];
2953 $base = substr($id, 0, -strlen('.'.$type));
2954 $url = ($build > 0 && !empty($location['ok'])) ? $location['url'].'/'.$base.'-'.$build.'.'.$type : '';
2955 $rows[] = array('label' => self::asset_runtime_health_label($health_id), 'delivery' => 'Static', 'status' => $status, 'status_label' => $status_label, 'detail' => $detail, 'url' => $url);
2956 }
2957 }
2958
2959 $s2o_missing = $selected_s2o && !is_file(self::s2o_file_path());
2960 $s2o_failed = $selected_s2o && !$s2o_missing && self::asset_http_target_failed('s2o', $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url']);
2961 $s2o_needed = $selected_s2o && ($dynamic_normal['css'] || $dynamic_normal['js']);
2962 $s2o_problem = $s2o_missing || $s2o_failed;
2963
2964 if($selected_s2o && $s2o_problem && $s2o_needed)
2965 $attention_items['s2o'] = ($s2o_missing) ? 'The selected s2Member-Only Dynamic Loader file <code>s2member-o.php</code> is missing; Full WordPress Dynamic Loader is being used automatically.' : 'The selected s2Member-Only Dynamic Loader could not be reached; Full WordPress Dynamic Loader is being used automatically.';
2966
2967 //260907.2203 Track missing/recovered loader transitions without logging every admin health check.
2968 //260909.2021 Keep those transitions in css-js.log even when the loader is not currently needed by fully static delivery.
2969 $s2o_missing_logged = (bool)get_option('ws_plugin__s2member_css_js_s2o_missing', FALSE);
2970 if($s2o_missing && !$s2o_missing_logged)
2971 {
2972 update_option('ws_plugin__s2member_css_js_s2o_missing', 1, FALSE);
2973 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 's2Member-Only Dynamic Loader file missing', 'result' => 'failure', 'file' => self::s2o_file_path(), 'fallback' => 'Full WordPress Dynamic Loader'));
2974 }
2975 else if($selected_s2o && !$s2o_missing && $s2o_missing_logged)
2976 {
2977 delete_option('ws_plugin__s2member_css_js_s2o_missing');
2978 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 's2Member-Only Dynamic Loader file recovered', 'result' => 'recovered', 'file' => self::s2o_file_path()));
2979 }
2980
2981 //260912.1956 Full WordPress is always either the configured dynamic route or the safety-net fallback, so keep its CSS/JS health visible even when static delivery is currently healthy.
2982 $full_checked = (!empty($http_health['full_checked'])) ? (int)$http_health['full_checked'] : 0;
2983 foreach(array('css' => 'CSS', 'js' => 'JS') as $type => $type_label)
2984 {
2985 $is_fallback = !empty($wp_loader_fallback[$type]);
2986 $failure_id = ($is_fallback) ? 'fallback:dynamic_'.$type : 'dynamic:dynamic_'.$type;
2987 $failed = !empty($failures[$failure_id]);
2988 $delivery_url = self::wordpress_dynamic_asset_url();
2989 $delivery_url = ($type === 'css')
2990 ? add_query_arg(array('ws_plugin__s2member_css' => '1', 'qcABC' => '1'), $delivery_url)
2991 : add_query_arg(array('ws_plugin__s2member_js_w_globals' => (defined('WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5') ? WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5 : '1'), 'qcABC' => '1'), $delivery_url);
2992 if($failed)
2993 {
2994 $status = 'error';
2995 $status_label = 'Failed';
2996 $detail = ($is_fallback) ? 'The Full WordPress Dynamic fallback could not be loaded or confirmed active.' : 'The Full WordPress Dynamic response could not be loaded or confirmed active.';
2997 }
2998 else if(!$full_checked)
2999 {
3000 $status = 'disabled';
3001 $status_label = 'Not checked yet';
3002 $detail = 'Recheck Asset Health to verify this delivery route.';
3003 }
3004 else if(!empty($wp_loader_active[$type]))
3005 {
3006 $event = self::asset_runtime_health_event('dynamic_'.$type);
3007 $status = $event['status'];
3008 $status_label = $event['label'];
3009 if(!empty($wp_loader_required[$type]))
3010 $detail = 'Full WordPress Dynamic Loader is the active compatibility-required route.';
3011 else
3012 $detail = ($is_fallback) ? 'Full WordPress Dynamic Loader is currently serving this asset as fallback.' : 'Full WordPress Dynamic Loader is the configured delivery route.';
3013 if($event['detail'])
3014 $detail .= ' '.$event['detail'];
3015 }
3016 else
3017 {
3018 $status = 'healthy';
3019 $status_label = 'Healthy';
3020 $detail = 'Full WordPress Dynamic fallback is available if the preferred delivery route cannot be used.';
3021 }
3022 $rows[] = array('label' => 'WP Loader '.$type_label, 'delivery' => ($is_fallback) ? 'Dynamic fallback' : 'Dynamic', 'status' => $status, 'status_label' => $status_label, 'detail' => $detail, 'url' => $delivery_url);
3023 }
3024
3025 //260912.0258 Only the Health Logkeeper mutates the rolling health log; this view reads the merged state without another read/modify/write race.
3026 $state = self::asset_health_log_state();
3027 $scores = self::asset_health_scores($state);
3028 $rolling_score = $scores['score'];
3029 $current_delivery_result = self::asset_health_current_delivery_result($failures);
3030 $standby_fallback_failures = array();
3031 if($full_checked && $current_delivery_result === 'okay')
3032 foreach(array('css' => 'CSS', 'js' => 'JS') as $type => $type_label)
3033 if(!empty($wp_loader_fallback[$type]) && !empty($failures['fallback:dynamic_'.$type]))
3034 {
3035 $standby_fallback_failures[$type] = TRUE;
3036 $attention_items['standby-fallback:'.$type] = 'WP Loader '.$type_label.' fallback is unavailable while the preferred '.$type_label.' delivery is still working.';
3037 }
3038 //260912.1956 A broken standby fallback can never improve Health: average its failed score with the established rolling score only while preferred delivery itself still works.
3039 if($standby_fallback_failures && $rolling_score !== NULL)
3040 $scores['score'] = ($rolling_score + 1.0) / 2;
3041 $overall = self::asset_health_status_from_score($scores['score'], $scores['latest_result']);
3042 $recent_issues = (!empty($http_health['recent_issues']) && is_array($http_health['recent_issues'])) ? $http_health['recent_issues'] : array();
3043 //260910.2350 Recent per-asset details explain a non-Green rolling score even when css-js.log is disabled; clear them only after the overall calculated Health is Green and no trusted/pending problem remains.
3044 if($overall === 'healthy' && !$failures && !$runtime_warnings && !self::asset_runtime_suspicions() && $recent_issues)
3045 {
3046 unset($http_health['recent_issues']);
3047 update_option('ws_plugin__s2member_asset_http_health', $http_health, FALSE);
3048 self::$asset_http_health_cache = $http_health;
3049 $recent_issues = array();
3050 }
3051 $labels = array(
3052 'unknown' => 'Not checked yet',
3053 'healthy' => 'Healthy',
3054 'delayed' => 'Recent issue',
3055 'attention' => 'Working, review suggested',
3056 'error' => 'Needs attention',
3057 );
3058 $summaries = array(
3059 'unknown' => 'No recent frontend asset-load health is available yet. This panel will run a trusted current-delivery check.',
3060 'healthy' => 'Recent frontend CSS/JavaScript asset loads are healthy.',
3061 'delayed' => 'Recent asset loads are mixed or include late activation, but they do not currently average into degraded delivery.',
3062 'attention' => 'Frontend asset delivery is working, but recent results or an unavailable fallback route suggest that the configuration should be reviewed.',
3063 'error' => 'Recent asset loads average into serious delivery failure. s2Member forms, buttons, behavior, or styling may currently be affected.',
3064 );
3065
3066 $not_green_since = (!empty($state['not_green_since'])) ? (int)$state['not_green_since'] : 0;
3067 $fallback_problem_since = ($standby_fallback_failures && !empty($http_health['fallback_problem_since'])) ? (int)$http_health['fallback_problem_since'] : 0;
3068 //260912.1956 Keep the existing rolling-health age, but let a continuously unavailable standby fallback start/extend the same non-Healthy review period without creating synthetic page-load events.
3069 if($overall !== 'healthy' && $fallback_problem_since > 0 && ($not_green_since <= 0 || $fallback_problem_since < $not_green_since))
3070 $not_green_since = $fallback_problem_since;
3071 if($overall === 'healthy')
3072 $not_green_since = 0;
3073 $six_hour_average = NULL;
3074 $notice_level = '';
3075 $notice_items = array();
3076 $notice_signature = '';
3077
3078 if($overall === 'error' && $not_green_since > 0)
3079 {
3080 //260910.0709 Red is immediate because the recent score says delivery is failing badly enough to threaten frontend behavior; no persistence delay is added.
3081 $notice_level = 'error';
3082 $notice_items = ($error_notice_items) ? $error_notice_items : array('score' => 'Recent CSS/JavaScript asset loads show repeated delivery failures severe enough that frontend s2Member functionality may be affected.');
3083 $notice_signature = 'error:'.$not_green_since;
3084 }
3085 else if($not_green_since > 0 && $not_green_since <= time() - 6 * HOUR_IN_SECONDS)
3086 {
3087 //260912.1956 Reuse the established six-hour review logic; when the standby fallback itself has stayed unavailable for the full period, average its failed score into the retained delivery history just as Current Health does.
3088 $six_hour_average = self::asset_health_six_hour_average($state);
3089 if($standby_fallback_failures && $fallback_problem_since > 0 && $fallback_problem_since <= time() - 6 * HOUR_IN_SECONDS)
3090 {
3091 if($six_hour_average === NULL)
3092 $six_hour_average = $rolling_score;
3093 if($six_hour_average !== NULL)
3094 $six_hour_average = ($six_hour_average + 1.0) / 2;
3095 }
3096 if($six_hour_average !== NULL && $six_hour_average <= 2.5)
3097 {
3098 $notice_level = 'attention';
3099 //260911.1705 Keep admin-facing health wording understandable without requiring familiarity with the internal Green/Yellow/Orange/Red state model.
3100 $notice_items = ($attention_items) ? $attention_items : array('score' => 'CSS/JavaScript asset-load health has remained substantially degraded across the latest six hours without returning to normal.');
3101 $notice_signature = 'attention:'.$not_green_since;
3102 }
3103 }
3104
3105 //260910.0709 Dismissal is scoped to severity + one continuous non-Green period; changing Orange-review severity to Red surfaces again, while Green clears the old dismissal before another period can begin.
3106 $dismissed = (string)get_option('ws_plugin__s2member_asset_notice_dismissed', '');
3107 $active_signatures = array_values(array_filter(array(($not_green_since > 0) ? 'error:'.$not_green_since : '', ($not_green_since > 0) ? 'attention:'.$not_green_since : '')));
3108 if($dismissed !== '' && !in_array($dismissed, $active_signatures, TRUE))
3109 delete_option('ws_plugin__s2member_asset_notice_dismissed');
3110
3111 return array(
3112 'status' => $overall,
3113 'status_label' => $labels[$overall],
3114 'summary' => $summaries[$overall],
3115 'rows' => $rows,
3116 'score' => $scores['score'],
3117 'rolling_score' => $rolling_score,
3118 'standby_fallback_failures' => array_keys($standby_fallback_failures),
3119 'request_score' => $scores['request_score'],
3120 'time_score' => $scores['time_score'],
3121 'request_count' => $scores['request_count'],
3122 'time_count' => $scores['time_count'],
3123 'recent_issues' => $recent_issues,
3124 'latest_issues' => (!empty($state['latest_issues']) && is_array($state['latest_issues'])) ? $state['latest_issues'] : array(),
3125 'six_hour_average' => $six_hour_average,
3126 'not_green_since' => $not_green_since,
3127 'notice_level' => $notice_level,
3128 'notice_items' => $notice_items,
3129 'notice_signature' => $notice_signature,
3130 );
3131 }
3132
3133 /**
3134 * Dismisses the current frontend-asset notice for the current continuous non-Green health period.
3135 *
3136 * @package s2Member\Utilities
3137 * @since 260909.2021
3138 *
3139 * @attaches-to ``add_action('admin_init');``
3140 * @return null
3141 */
3142 public static function dismiss_static_assets_admin_notice()
3143 {
3144 if(!is_admin() || !current_user_can('create_users') || empty($_GET['s2member-dismiss-asset-health-notice']))
3145 return;
3146
3147 check_admin_referer('s2member-dismiss-asset-health-notice');
3148 $health = self::frontend_asset_health(TRUE);
3149 if(!empty($health['notice_signature']))
3150 update_option('ws_plugin__s2member_asset_notice_dismissed', (string)$health['notice_signature'], FALSE);
3151
3152 wp_safe_redirect(wp_get_referer() ? wp_get_referer() : admin_url());
3153 exit;
3154 }
3155
3156 /**
3157 * Displays one non-Green-period-scoped admin-wide notice for red failures or persistent degraded health.
3158 *
3159 * Missing or browser-confirmed unreachable frontend assets remain part of the Red diagnosis when usable delivery/fallback also fails; preferred-route failures with a working fallback are not treated as Red.
3160 * Red means the recent weighted asset loads average into serious failure and is immediate.
3161 * Orange remains non-alarming unless six hours have passed without Green and the equal-block
3162 * rolling six-hour average remains below Yellow territory. Current Yellow may therefore still
3163 * surface the calm review notice when the longer recent history remains substantially degraded.
3164 *
3165 * @package s2Member\Utilities
3166 * @since 260903.0612
3167 *
3168 * @attaches-to ``add_action('admin_notices');``
3169 * @return null
3170 */
3171 public static function static_assets_admin_notice()
3172 {
3173 if(!current_user_can('create_users') || (defined('DOING_AJAX') && DOING_AJAX))
3174 return;
3175
3176 $health = self::frontend_asset_health();
3177 if(empty($health['notice_items']) || empty($health['notice_signature']) || empty($health['notice_level']))
3178 return;
3179 $dismissed = (string)get_option('ws_plugin__s2member_asset_notice_dismissed', '');
3180 if($dismissed === (string)$health['notice_signature'])
3181 return;
3182 //260911.0012 A dismissed Red problem also suppresses the later calmer Orange review notice in the same non-Green period. A dismissed Orange notice never suppresses a later Red escalation.
3183 if($health['notice_level'] !== 'error' && !empty($health['not_green_since']) && $dismissed === 'error:'.(int)$health['not_green_since'])
3184 return;
3185
3186 //260911.1707 Give the specific asset-health reason a compact, visually distinct line without requiring familiarity with the internal health-state colors.
3187 $items = array();
3188 foreach($health['notice_items'] as $item)
3189 $items[] = '&bull;&nbsp; <strong><em>'.$item.'</em></strong>';
3190 $settings_url = add_query_arg('s2member-open-panel', 'frontend-static-assets', admin_url('/admin.php?page=ws-plugin--s2member-gen-ops')).'#ws-plugin--s2member-asset-health';
3191 $dismiss_url = wp_nonce_url(add_query_arg('s2member-dismiss-asset-health-notice', '1', admin_url()), 's2member-dismiss-asset-health-notice');
3192
3193 $_notice_items = '<span style="display:block; margin:.4em 0 .45em .65em;">'.implode('<br />', $items).'</span>';
3194 if($health['notice_level'] === 'error')
3195 {
3196 $message = 'Recent frontend CSS/JavaScript asset loads average into serious delivery failure. This can affect s2Member forms, buttons, behavior, or styling.'.$_notice_items.'<a href="'.esc_url($settings_url).'">Open CSS/JS Asset Health</a> for the current delivery details and troubleshooting.';
3197 c_ws_plugin__s2member_admin_notices::display_branded_notice('s2Member CSS/JS Asset Delivery Problem', $message, TRUE, $dismiss_url);
3198 }
3199 else
3200 {
3201 $message = 'Frontend CSS/JavaScript asset-load health has remained substantially degraded across the latest six hours without returning to normal. Delivery may currently be improving or may still be working through fallback. This is a suggestion to review the configuration, not an emergency.'.$_notice_items.'<a href="'.esc_url($settings_url).'">Open CSS/JS Asset Health</a> to review the current delivery details.';
3202 c_ws_plugin__s2member_admin_notices::display_branded_notice('s2Member CSS/JS Asset Health: Review Suggested', $message, FALSE, $dismiss_url);
3203 }
3204 unset($_notice_items);
3205 return;
3206 }
3207
3208 /**
3209 * Prints an infrequent trusted browser-side reachability probe for active frontend assets.
3210 *
3211 * Healthy static files use HEAD.
3212 * The s2Member-Only Dynamic Loader (s2member-o.php) uses its tiny pre-WordPress health mode.
3213 * A frontend runtime suspicion forces one full cache-busted activation check for that exact URL.
3214 *
3215 * @package s2Member\Utilities
3216 * @since 260904.2110
3217 *
3218 * @attaches-to ``add_action('admin_footer');``
3219 * @attaches-to ``add_action('wp_footer');``
3220 * @return null
3221 */
3222 public static function asset_http_health_probe()
3223 {
3224 if(!current_user_can('create_users') || (defined('DOING_AJAX') && DOING_AJAX))
3225 return;
3226
3227 $health_log = self::asset_health_log_state();
3228 $scores = self::asset_health_scores($health_log);
3229 $health = self::asset_http_health_state();
3230 $has_failures = is_array($health) && !empty($health['failures']);
3231 $has_suspicions = (bool)self::asset_runtime_suspicions();
3232 $current_status = (!empty($health_log['status'])) ? (string)$health_log['status'] : 'unknown';
3233 $full = self::$asset_health_force_full_probe || !$scores['time_count'] || $has_failures || $has_suspicions || !in_array($current_status, array('unknown', 'healthy'), TRUE);
3234 $targets = self::asset_http_health_targets($full);
3235 if(!$targets)
3236 return;
3237
3238 $target_hash = self::asset_http_health_target_hash($targets);
3239 $interval = ($full || $has_failures || $has_suspicions) ? MINUTE_IN_SECONDS : 10 * MINUTE_IN_SECONDS;
3240 $auto_due = $has_suspicions || !is_array($health) || empty($health['checked']) || empty($health['target_hash']) || (string)$health['target_hash'] !== $target_hash || (int)$health['checked'] < time() - $interval;
3241 if(!$auto_due && !self::$asset_health_force_full_probe)
3242 return;
3243
3244 $config = array(
3245 'targets' => array_values($targets),
3246 'target_hash' => $target_hash,
3247 'ajax_url' => admin_url('admin-ajax.php'),
3248 'nonce' => wp_create_nonce('ws-plugin--s2member-asset-http-health'),
3249 'reload_on_change' => is_admin(),
3250 'full' => (bool)$full,
3251 'auto_run' => (bool)$auto_due,
3252 );
3253
3254 //260910.0818 The same trusted probe can run automatically for stale/no-current evidence and on demand from the Health panel; only an explicit recheck is allowed to reset active score history after a clean Okay result.
3255 echo '<script type="text/javascript">(function(c){if(!window.fetch||!window.URL||!window.Promise)return;var b=document.getElementById("ws-plugin--s2member-recheck-asset-health"),s=document.getElementById("ws-plugin--s2member-recheck-asset-health-status"),busy=false;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?"":"Dynamic Loader health response could not be verified."}});if(t.mode==="activation-tag")return f(t,"GET",i,true).then(function(r){if(r.ok&&t.activation_tags)for(var j=0;j<t.activation_tags.length;j++)if(r.text.indexOf(t.activation_tags[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 asset could not be verified as active."}});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"}})})}function run(reset,retried){if(busy)return;busy=true;if(reset&&b)b.disabled=true;if(reset&&s)s.textContent="Checking current asset delivery...";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)+"&full="+(c.full?"1":"0")+"&reset_health="+(reset?"1":"0")+"&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){busy=false;if(j&&j.success&&j.data&&j.data.stale){if(!retried&&j.data.targets&&j.data.target_hash){c.targets=j.data.targets;c.target_hash=j.data.target_hash;if(reset&&s)s.textContent=j.data.recheck_message||"Delivery changed. Rechecking...";return run(reset,true)}if(reset&&s)s.textContent="Delivery changed again. Refreshing...";window.location.reload();return}if(reset&&s)s.textContent=(j&&j.success&&j.data&&j.data.recheck_message)?j.data.recheck_message:"Check complete.";if(reset&&b)b.disabled=false;if((reset||c.reload_on_change)&&j&&j.success&&j.data&&j.data.reload)window.location.reload()}).catch(function(){busy=false;if(reset&&b)b.disabled=false;if(reset&&s)s.textContent="The check could not be completed."})}if(b)b.addEventListener("click",function(){run(true,false)},false);if(c.auto_run)run(false,false)})('.wp_json_encode($config).');</script>' . "\n";
3256 return;
3257 }
3258
3259 /**
3260 * Stores trusted administrator-browser asset probe results.
3261 *
3262 * @package s2Member\Utilities
3263 * @since 260904.2110
3264 *
3265 * @return null Exits through WordPress JSON helpers.
3266 */
3267 public static function ajax_asset_http_health_report()
3268 {
3269 check_ajax_referer('ws-plugin--s2member-asset-http-health');
3270 if(!current_user_can('create_users'))
3271 wp_send_json_error(array('message' => 'You do not have permission to report s2Member asset health.'), 403);
3272
3273 $full = !empty($_POST['full']);
3274 $reset_health = $full && !empty($_POST['reset_health']);
3275 $targets = self::asset_http_health_targets($full);
3276 $target_hash = self::asset_http_health_target_hash($targets);
3277 if(empty($_POST['target_hash']) || (string)wp_unslash($_POST['target_hash']) !== $target_hash)
3278 wp_send_json_success(array('stale' => TRUE, 'reload' => FALSE, 'targets' => array_values($targets), 'target_hash' => $target_hash, 'recheck_message' => 'Delivery targets changed. Rechecking current routes...'));
3279
3280 $results = (!empty($_POST['results'])) ? json_decode(wp_unslash($_POST['results']), TRUE) : array();
3281 $by_id = array();
3282 if(is_array($results))
3283 foreach($results as $result)
3284 if(is_array($result) && !empty($result['id']))
3285 $by_id[(string)$result['id']] = $result;
3286
3287 $old = self::asset_http_health_state();
3288 $old_failures = (is_array($old) && !empty($old['failures']) && is_array($old['failures'])) ? $old['failures'] : array();
3289 $runtime_warnings = (is_array($old) && !empty($old['runtime_warnings']) && is_array($old['runtime_warnings'])) ? $old['runtime_warnings'] : array();
3290 $recent_issues = (is_array($old) && !empty($old['recent_issues']) && is_array($old['recent_issues'])) ? $old['recent_issues'] : array(); //260910.2346 Preserve compact per-asset troubleshooting context across probes until the calculated overall Health returns Green.
3291 foreach($runtime_warnings as $key => $warning)
3292 if(empty($warning['reported']) || (int)$warning['reported'] < time() - HOUR_IN_SECONDS)
3293 unset($runtime_warnings[$key]);
3294 $old_runtime_warnings = $runtime_warnings; //260907.2203 Preserve prior warning state so only new trusted transitions are logged.
3295 $old_health_log = self::asset_health_log_state();
3296 $old_health_status = (!empty($old_health_log['status'])) ? (string)$old_health_log['status'] : 'unknown';
3297
3298 $failures = array();
3299 $failure_contexts = array();
3300 $suspicions = self::asset_runtime_suspicions();
3301
3302 foreach($targets as $id => $target)
3303 {
3304 $result = (isset($by_id[$id]) && is_array($by_id[$id])) ? $by_id[$id] : array();
3305 if(empty($result['ok']))
3306 {
3307 $failure_id = (!empty($target['failure_id'])) ? (string)$target['failure_id'] : $id;
3308 $failures[$failure_id] = array(
3309 'url' => (!empty($target['failure_url'])) ? (string)$target['failure_url'] : (string)$target['url'],
3310 'label' => (string)$target['label'],
3311 'status' => (!empty($result['status'])) ? (int)$result['status'] : 0,
3312 'content_type' => (!empty($result['content_type'])) ? substr(sanitize_text_field((string)$result['content_type']), 0, 100) : '',
3313 'detail' => (!empty($result['detail'])) ? substr(sanitize_text_field((string)$result['detail']), 0, 160) : '',
3314 );
3315 $recent_issues = self::add_asset_health_recent_issue($recent_issues, 'failure-'.$failure_id, 'failed', (string)$target['label'], (!empty($failures[$failure_id]['detail'])) ? (string)$failures[$failure_id]['detail'] : 'Trusted browser check failed.', (string)$failures[$failure_id]['url']);
3316 if(!empty($target['suspicion']) && is_array($target['suspicion']))
3317 $failure_contexts[$failure_id] = array(
3318 'event_time' => (!empty($target['suspicion']['event_time'])) ? (int)$target['suspicion']['event_time'] : time(),
3319 'page_id' => (!empty($target['suspicion']['page_id'])) ? (int)$target['suspicion']['page_id'] : 0,
3320 'page_path' => (!empty($target['suspicion']['page_path'])) ? (string)$target['suspicion']['page_path'] : '',
3321 );
3322 if(strpos($failure_id, 'fallback:') !== 0 && (!isset($old_failures[$failure_id]) || serialize($old_failures[$failure_id]) !== serialize($failures[$failure_id])))
3323 {
3324 $_failure_suspicion = (!empty($target['suspicion']) && is_array($target['suspicion'])) ? $target['suspicion'] : array();
3325 $_failure_page = array('page_id' => (!empty($_failure_suspicion['page_id'])) ? (int)$_failure_suspicion['page_id'] : 0, 'page_path' => (!empty($_failure_suspicion['page_path'])) ? (string)$_failure_suspicion['page_path'] : '');
3326 $_failure_event_time = (!empty($_failure_suspicion['event_time'])) ? (int)$_failure_suspicion['event_time'] : time();
3327 self::queue_asset_health_issue_snapshot('failed', (string)$target['label'], (!empty($failures[$failure_id]['detail'])) ? (string)$failures[$failure_id]['detail'] : 'Trusted browser check failed.', array('asset' => $failure_id, 'delivery' => (!empty($_failure_suspicion['delivery'])) ? (string)$_failure_suspicion['delivery'] : ''), $_failure_page, $_failure_event_time);
3328 }
3329 }
3330 else if(!empty($target['suspicion_key']) && !empty($target['suspicion']))
3331 {
3332 $key = (string)$target['suspicion_key'];
3333 $previous_warning = (isset($runtime_warnings[$key]) && is_array($runtime_warnings[$key])) ? $runtime_warnings[$key] : array();
3334 $previous_count = (!empty($previous_warning['count'])) ? max(1, (int)$previous_warning['count']) : (($previous_warning) ? 1 : 0);
3335 $runtime_warnings[$key] = array(
3336 'id' => (string)$target['suspicion']['id'],
3337 'url' => (string)$target['suspicion']['url'],
3338 'delivery' => (string)$target['suspicion']['delivery'],
3339 'event_time' => (!empty($target['suspicion']['event_time'])) ? (int)$target['suspicion']['event_time'] : time(),
3340 'page_id' => (!empty($target['suspicion']['page_id'])) ? (int)$target['suspicion']['page_id'] : 0,
3341 'page_path' => (!empty($target['suspicion']['page_path'])) ? (string)$target['suspicion']['page_path'] : '',
3342 'first_reported' => (!empty($previous_warning['first_reported'])) ? (int)$previous_warning['first_reported'] : ((!empty($previous_warning['reported'])) ? (int)$previous_warning['reported'] : time()),
3343 'reported' => time(),
3344 'count' => $previous_count + 1,
3345 );
3346 $recent_issues = self::add_asset_health_recent_issue($recent_issues, 'late-'.(string)$target['suspicion']['id'], 'late', self::asset_runtime_health_label((string)$target['suspicion']['id']), 'A frontend page could not confirm this asset within the configured wait time, but the trusted follow-up check verified the expected asset response.', (string)$target['suspicion']['url'], (string)$target['suspicion']['delivery']);
3347 }
3348 if(!empty($target['suspicion_key']))
3349 unset($suspicions[(string)$target['suspicion_key']]);
3350 }
3351
3352 update_option('ws_plugin__s2member_asset_runtime_suspicions', $suspicions, FALSE);
3353
3354 //260907.2203 Keep an operational history of newly confirmed failures, recoveries, and runtime warnings.
3355 //260910.0818 Routine Okay/Fallback/Late/Failed asset loads stay only in the fixed-size non-autoloaded health log so operational history is not flooded by normal frontend traffic.
3356 foreach($failures as $id => $failure)
3357 if(!isset($old_failures[$id]) || serialize($old_failures[$id]) !== serialize($failure))
3358 {
3359 $_failure_log_context = (!empty($failure_contexts[$id]) && is_array($failure_contexts[$id])) ? $failure_contexts[$id] : array();
3360 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array_merge(array(
3361 'event' => 'CSS/JS delivery health failure', 'result' => 'failure', 'target' => $id, 'details' => $failure,
3362 'fallback' => ($id === 's2o' || strpos((string)$id, 'static:') === 0) ? 'Full WordPress Dynamic Loader' : 'none',
3363 ), $_failure_log_context));
3364 }
3365 foreach($old_failures as $id => $failure)
3366 if(!isset($failures[$id]))
3367 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'CSS/JS delivery health recovered', 'result' => 'recovered', 'target' => $id, 'previous_details' => $failure));
3368 foreach($runtime_warnings as $key => $warning)
3369 if(!isset($old_runtime_warnings[$key]))
3370 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'CSS/JS runtime warning confirmed', 'result' => 'warning', 'details' => $warning, 'delivery_changed' => FALSE));
3371
3372 $full_checked = ($full) ? time() : ((!empty($old['full_checked'])) ? (int)$old['full_checked'] : 0);
3373 $fallback_problem = !empty($failures['fallback:dynamic_css']) || !empty($failures['fallback:dynamic_js']);
3374 $fallback_problem_since = 0;
3375 if($full && $fallback_problem)
3376 $fallback_problem_since = (!empty($old['fallback_problem_since'])) ? (int)$old['fallback_problem_since'] : time();
3377 else if(!$full && !empty($old['fallback_problem_since']))
3378 $fallback_problem_since = (int)$old['fallback_problem_since'];
3379 //260912.1956 Preserve when the complete route set was last checked, and how long a WordPress fallback has stayed unavailable, without turning standby-route health into extra page-load records.
3380 $new_health = array('checked' => time(), 'full_checked' => $full_checked, 'fallback_problem_since' => $fallback_problem_since, 'target_hash' => $target_hash, 'failures' => $failures, 'runtime_warnings' => $runtime_warnings, 'recent_issues' => $recent_issues);
3381 update_option('ws_plugin__s2member_asset_http_health', $new_health, FALSE);
3382 self::$asset_http_health_cache = $new_health;
3383
3384 $load_result = '';
3385 $recheck_message = '';
3386 $new_health_status = $old_health_status;
3387 if($full)
3388 {
3389 //260910.0818 A full trusted probe contributes one Okay/Fallback/Failed asset load through the same scoring path as frontend evidence; only an explicit successful admin recheck may start a clean epoch.
3390 $load_result = self::asset_health_current_delivery_result($failures);
3391 $standby_fallback_failures = ($load_result === 'okay') ? array_intersect_key($failures, array('fallback:dynamic_css' => TRUE, 'fallback:dynamic_js' => TRUE)) : array();
3392 $reset_on_ok = $reset_health && $load_result === 'okay' && !$standby_fallback_failures;
3393 //260913.0102 Trusted failure transitions are queued separately as issue snapshots, so this scored recheck does not duplicate them in Latest Issues.
3394 self::queue_asset_health_load($load_result, $reset_on_ok);
3395 //260912.1956 A standby fallback outage is historical issue context, not an extra page-load; queue it once on the failure transition while Current Health applies the separate safety-net score.
3396 foreach($standby_fallback_failures as $failure_id => $failure)
3397 if(empty($old_failures[$failure_id]))
3398 {
3399 $type_label = (substr($failure_id, -3) === '_js') ? 'JS' : 'CSS';
3400 self::queue_asset_health_issue_snapshot('fallback-unavailable', 'WP Loader '.$type_label, 'The Full WordPress Dynamic fallback could not be loaded or confirmed active while preferred '.$type_label.' delivery was still working.');
3401 }
3402 //260912.0258 Trusted administrator probes run the Health Logkeeper immediately so their response reflects the event just queued; frontend pages remain queue-only.
3403 self::run_health_logkeeper();
3404 $new_health_log = self::asset_health_log_state();
3405 $new_health_status = (!empty($new_health_log['status'])) ? (string)$new_health_log['status'] : 'unknown';
3406 if($reset_health)
3407 $recheck_message = ($reset_on_ok) ? 'Current delivery and its fallback are healthy. Recent scoring history was reset.' : (($standby_fallback_failures) ? 'Current delivery is working, but a fallback route is unavailable. Recent health history was kept.' : (($load_result === 'fallback') ? 'Current delivery is working through fallback. Recent health history was kept.' : 'Current delivery still has a failure. Recent health history was kept.'));
3408 }
3409
3410 $health_changed = serialize($old_failures) !== serialize($failures) || serialize($old_runtime_warnings) !== serialize($runtime_warnings) || $old_health_status !== $new_health_status;
3411 wp_send_json_success(array(
3412 'failures' => count($failures),
3413 'runtime_warnings' => count($runtime_warnings),
3414 'load_result' => $load_result,
3415 'recheck_message' => $recheck_message,
3416 'reload' => $health_changed || $reset_health,
3417 ));
3418 }
3419
3420 /**
3421 * Prints the real-page asset activation monitor.
3422 *
3423 * A short configurable wait after window.load avoids racing normal delivery; healthy pages make no runtime-suspicion request.
3424 * An asset that cannot be confirmed active after that wait is a low-trust timing suspicion only.
3425 * The real page reports it for a later trusted browser check; unlike the first v260909 monitor, a recoverable miss no longer injects a WordPress fallback request merely because an optimizer may have delayed execution.
3426 *
3427 * @package s2Member\Utilities
3428 * @since 260904.2255
3429 *
3430 * @attaches-to ``add_action('wp_footer');``
3431 * @return null
3432 */
3433 public static function page_asset_runtime_monitor()
3434 {
3435 if(is_admin() || !self::$page_asset_expectations)
3436 return;
3437
3438 //260912.0304 Queue one page-level asset load now, preserving compact fallback context before healthy traffic can push it out of the rolling score window.
3439 $page_result = self::page_asset_health_load_result();
3440 $load_record = self::queue_asset_health_load($page_result, FALSE, array(), self::page_asset_health_issue($page_result));
3441 $load = (!empty($load_record['load']) && is_array($load_record['load'])) ? $load_record['load'] : array();
3442
3443 $expectations = array();
3444 foreach(self::$page_asset_expectations as $expectation)
3445 $expectations[] = array(
3446 (string)$expectation['id'],
3447 (string)$expectation['asset_id'],
3448 (string)$expectation['url'],
3449 (string)$expectation['delivery'],
3450 (string)$expectation['tag_value'],
3451 (string)$expectation['signature'],
3452 );
3453 //260912.0522 The wait setting changes only when an asset becomes a Late suspicion; it never delays loading and does not trigger speculative fallback injection.
3454 $wait_seconds = (!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['asset_health_wait_seconds'])) ? (int)$GLOBALS['WS_PLUGIN__']['s2member']['o']['asset_health_wait_seconds'] : 3;
3455 $wait_seconds = max(1, min(60, $wait_seconds));
3456 $config = array('ajax_url' => admin_url('admin-ajax.php'), 'expectations' => $expectations, 'delay' => $wait_seconds * 1000, 'load' => $load);
3457
3458 //260912.0522 A Late activation is diagnostic evidence, not proof that loading failed; report it without racing an optimizer with a second CSS/JS response.
3459 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 n(e){var i="ws-plugin--s2member-asset-health-"+e[0].replace(/_/g,"-"),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[e[0]]===e[4]);return !window.getComputedStyle||String(getComputedStyle(n(e)).zIndex)===e[4]}function report(m){if(!window.fetch||!m.length)return;fetch(c.ajax_url,{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))+"&load="+encodeURIComponent(JSON.stringify(c.load||{}))}).catch(function(){})}function check(){var m=c.expectations.filter(function(e){return !ok(e)});if(m.length)report(m)}c.expectations.filter(function(e){return t(e)==="css"}).forEach(n);function go(){setTimeout(check,c.delay)}document.readyState==="complete"?go():addEventListener("load",go,false)})('.wp_json_encode($config).');</script>' . "\n";
3460 return;
3461 }
3462
3463 /**
3464 * Records signed low-trust frontend runtime suspicions without changing delivery state.
3465 *
3466 * Reports are rate-limited and only force a later trusted administrator-browser confirmation.
3467 * The standalone AJAX reporter is now the normal path; the recovery-query validator remains for compatibility with already-cached pages from the first v260909 monitor.
3468 *
3469 * @package s2Member\Utilities
3470 * @since 260905.0009
3471 *
3472 * @param array $missing Missing runtime expectations.
3473 * @param array $load Optional signed metadata for the page asset load being corrected to Late.
3474 * @return int Number of newly recorded suspicions.
3475 */
3476 protected static function record_asset_runtime_suspicions($missing = array(), $load = array())
3477 {
3478 if(!is_array($missing) || !$missing)
3479 return 0;
3480 $missing = array_slice($missing, 0, 4);
3481 $suspicions = self::asset_runtime_suspicions();
3482 $recorded = 0;
3483 $valid_late_page = FALSE;
3484 $late_issues = array();
3485 $page_context = self::verified_asset_health_page_context($load);
3486 $event_time = (!empty($page_context['page_id']) || !empty($page_context['page_path'])) && !empty($load['load_time']) ? (int)$load['load_time'] : time();
3487 foreach($missing as $expectation)
3488 {
3489 if(is_array($expectation) && isset($expectation[0]) && !isset($expectation['id']))
3490 $expectation = self::expand_asset_runtime_expectation($expectation);
3491 if(!is_array($expectation) || empty($expectation['signature']))
3492 continue;
3493 $signature = (string)$expectation['signature'];
3494 unset($expectation['signature']);
3495 if(!hash_equals(self::asset_runtime_expectation_signature($expectation), $signature) || !self::asset_runtime_expectation_is_current($expectation))
3496 continue;
3497 $valid_late_page = TRUE;
3498 $late_issues[] = array(
3499 'asset' => (string)$expectation['id'],
3500 'label' => self::asset_runtime_health_label((string)$expectation['id']),
3501 'delivery' => (!empty($expectation['delivery'])) ? (string)$expectation['delivery'] : '',
3502 'detail' => 's2Member could not confirm that this asset became active within the configured wait time.',
3503 );
3504 $key = md5((string)$expectation['id']."\0".(string)$expectation['url']."\0".(string)$expectation['activation_tag']);
3505 if(get_transient('ws_plugin__s2member_asset_runtime_suspect_'.$key))
3506 continue;
3507
3508 $first_report = empty($suspicions[$key]); //260907.2203 Avoid duplicating the same low-trust runtime suspicion in the log.
3509
3510 set_transient('ws_plugin__s2member_asset_runtime_suspect_'.$key, 1, MINUTE_IN_SECONDS);
3511 $expectation['reported'] = time();
3512 $expectation['event_time'] = $event_time;
3513 $expectation['page_id'] = (!empty($page_context['page_id'])) ? (int)$page_context['page_id'] : 0;
3514 $expectation['page_path'] = (!empty($page_context['page_path'])) ? (string)$page_context['page_path'] : '';
3515 $expectation['signature'] = $signature;
3516 $suspicions[$key] = $expectation;
3517
3518 //260913.0048 Record accepted signed page context with the operational issue so css-js.log can correlate intermittent failures without storing query strings.
3519 if($first_report)
3520 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array(
3521 'event' => 'Frontend CSS/JS runtime issue reported', 'result' => 'suspected', 'asset' => (string)$expectation['id'],
3522 'delivery' => (string)$expectation['delivery'], 'url' => (string)$expectation['url'], 'event_time' => $event_time,
3523 'page_id' => (!empty($page_context['page_id'])) ? (int)$page_context['page_id'] : 0, 'page_path' => (!empty($page_context['page_path'])) ? (string)$page_context['page_path'] : '', 'trusted_confirmation_pending' => TRUE,
3524 ));
3525
3526 $recorded++;
3527 }
3528 if($recorded)
3529 update_option('ws_plugin__s2member_asset_runtime_suspicions', $suspicions, FALSE);
3530 if($valid_late_page)
3531 {
3532 //260913.0048 One page still contributes one Late score, while all affected physical assets can share that signed page/time context in bounded Latest Issues.
3533 //260910.2346 Current pages send signed load metadata so Late corrects the original page instead of becoming a second load. Cached first-v260909 pages have no load metadata, so only a newly accepted/rate-limited suspicion contributes standalone Late evidence.
3534 if($load || $recorded)
3535 self::queue_asset_health_load('late', FALSE, $load, array('items' => $late_issues));
3536 }
3537 return $recorded;
3538 }
3539
3540 /**
3541 * Records signed runtime suspicions carried by a page-local WordPress recovery request.
3542 *
3543 * @package s2Member\Utilities
3544 * @since 260905.0009
3545 *
3546 * @return int Number of newly recorded suspicions.
3547 */
3548 public static function record_asset_runtime_recovery_suspicion()
3549 {
3550 if(empty($_GET['s2member_asset_runtime_suspect']))
3551 return 0;
3552 $missing = json_decode(wp_unslash($_GET['s2member_asset_runtime_suspect']), TRUE);
3553 return self::record_asset_runtime_suspicions($missing);
3554 }
3555
3556 /**
3557 * Records a low-trust frontend runtime suspicion without changing delivery state.
3558 *
3559 * This endpoint remains available for misses where loading a full fallback could duplicate JavaScript that already ran.
3560 *
3561 * @package s2Member\Utilities
3562 * @since 260904.2255
3563 *
3564 * @return null Exits through WordPress JSON helpers.
3565 */
3566 public static function ajax_asset_runtime_suspicion()
3567 {
3568 $missing = (!empty($_POST['missing'])) ? json_decode(wp_unslash($_POST['missing']), TRUE) : array();
3569 $load = (!empty($_POST['load'])) ? json_decode(wp_unslash($_POST['load']), TRUE) : array();
3570 wp_send_json_success(array('recorded' => self::record_asset_runtime_suspicions($missing, $load)));
3571 }
3572
3573 /**
3574 * Returns whether an enabled static asset type intentionally requires dynamic delivery for compatibility.
3575 *
3576 * A source/build/filesystem failure is not an intentional requirement and remains a real fallback condition.
3577 *
3578 * @package s2Member\Utilities
3579 * @since 260913.2001
3580 *
3581 * @param string $type `css` or `js`.
3582 * @return array Requirement state and site-owner detail.
3583 */
3584 protected static function static_type_dynamic_requirement($type = '')
3585 {
3586 $type = strtolower((string)$type);
3587 if(!in_array($type, array('css', 'js'), TRUE))
3588 return array('required' => FALSE, 'detail' => '');
3589
3590 $required = FALSE;
3591 $details = array();
3592 foreach(self::static_asset_ids($type, 'all') as $id)
3593 {
3594 $definition = self::static_asset_definition($id, FALSE);
3595 if(!empty($definition['ok']))
3596 continue;
3597 if(empty($definition['dynamic_required']))
3598 return array('required' => FALSE, 'detail' => '');
3599 $required = TRUE;
3600 if(!empty($definition['health_detail']))
3601 $details[] = (string)$definition['health_detail'];
3602 else if(!empty($definition['error']))
3603 $details[] = (string)$definition['error'];
3604 }
3605 return array('required' => $required, 'detail' => implode(' ', array_unique($details)));
3606 }
3607
3608 /**
3609 * Returns the source definition for one currently enabled/compatible generated frontend asset file.
3610 *
3611 * @package s2Member\Utilities
3612 * @since 260903.0525
3613 *
3614 * @param string $id Logical generated filename.
3615 * @param bool $include_sources Build ordered source definitions only when an asset actually needs generation.
3616 * @return array Definition result.
3617 */
3618 protected static function static_asset_definition($id = '', $include_sources = TRUE)
3619 {
3620 $id = strtolower((string)$id);
3621 if(!in_array($id, array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js'), TRUE))
3622 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Invalid static asset ID');
3623 $type = substr(strrchr($id, '.'), 1);
3624 $pro_file = strpos($id, 's2member-pro.') === 0;
3625 $combine = !$pro_file && !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_assets_combine']);
3626 $o = $GLOBALS['WS_PLUGIN__']['s2member']['o'];
3627 $c = $GLOBALS['WS_PLUGIN__']['s2member']['c'];
3628 if($pro_file && !in_array($id, self::static_asset_ids($type, 'all'), TRUE))
3629 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Separate Pro static asset is not active');
3630
3631 if($type === 'css')
3632 {
3633 if(empty($o['static_css']))
3634 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Static CSS Delivery is disabled');
3635
3636 //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.
3637 $framework_dynamic = has_action('ws_plugin__s2member_before_css') || isset($GLOBALS['wp_filter']['all']);
3638 $hook_dynamic = has_action('ws_plugin__s2member_during_css');
3639 $hook_dynamic = (bool)apply_filters('ws_plugin__s2member_dynamic_css_required', $hook_dynamic, get_defined_vars());
3640
3641 if($framework_dynamic || $hook_dynamic)
3642 {
3643 //260913.2001 Explain which compatibility condition makes Dynamic delivery intentional instead of collapsing all such cases into a generic fallback error.
3644 $reasons = array();
3645 if(has_action('ws_plugin__s2member_before_css'))
3646 $reasons[] = 'the "ws_plugin__s2member_before_css" hook has a customization';
3647 if(isset($GLOBALS['wp_filter']['all']))
3648 $reasons[] = 'WordPress\'s global "all" hook is active';
3649 if($hook_dynamic)
3650 $reasons[] = (has_action('ws_plugin__s2member_during_css')) ? 'the "ws_plugin__s2member_during_css" hook has a customization that must remain dynamic' : 'the "ws_plugin__s2member_dynamic_css_required" filter requires dynamic CSS';
3651 $error = 'Static CSS cannot be used with the current request/configuration because '.implode('; ', array_unique($reasons)).'. Full WordPress Dynamic Loader is required so the current hooks and customizations remain available.';
3652 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => $error, 'dynamic_required' => TRUE);
3653 }
3654 if(!$include_sources)
3655 return array('ok' => TRUE, 'sources' => array(), 'minify' => !empty($o['static_css_minify']), 'error' => '');
3656
3657 $sources = ($pro_file) ? array() : array(array('file' => $c['dir'].'/src/includes/s2member.css', 'preserve_header' => TRUE));
3658 if(!$pro_file)
3659 $sources = (array)apply_filters('ws_plugin__s2member_static_css_sources', $sources, get_defined_vars());
3660 if($pro_file || $combine)
3661 $sources = (array)apply_filters('ws_plugin__s2member_static_pro_css_sources', $sources, get_defined_vars());
3662 if(!$sources)
3663 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'No static CSS sources are available for '.$id);
3664 return array('ok' => TRUE, 'sources' => $sources, 'minify' => !empty($o['static_css_minify']), 'error' => '');
3665 }
3666 if($type === 'js')
3667 {
3668 if(empty($o['static_js']))
3669 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Static JS Delivery is disabled');
3670 if(!function_exists('wp_add_inline_script'))
3671 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Static JS requires WordPress 4.5+');
3672
3673 $page_text = self::static_js_text_delivery() === 'page';
3674 if($page_text && !self::static_js_page_text_supported())
3675 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Loading JavaScript text with each WordPress page requires a current s2Member Pro version');
3676
3677 $js_api_constants_enabled = (bool)apply_filters('ws_plugin__s2member_js_api_constants_enable', FALSE);
3678 if($page_text)
3679 {
3680 //260906.2049 Text and other page-specific values resolve in the normal HTML request, so they do not make the external JavaScript dynamic.
3681 $framework_dynamic = $js_api_constants_enabled || has_action('ws_plugin__s2member_before_js_w_globals') || isset($GLOBALS['wp_filter']['all']);
3682 }
3683 else
3684 {
3685 $site_locale = (string)get_option('WPLANG');
3686 if(!$site_locale && defined('WPLANG'))
3687 $site_locale = (string)WPLANG;
3688 $site_locale = ($site_locale) ? $site_locale : 'en_US';
3689 $current_locale = (function_exists('determine_locale')) ? (string)determine_locale() : (string)get_locale();
3690 $framework_dynamic = $js_api_constants_enabled || has_action('ws_plugin__s2member_before_js_w_globals') || $current_locale !== $site_locale || has_filter('ws_plugin__s2member_files_dir')
3691 || 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')
3692 || isset($GLOBALS['wp_filter']['all']);
3693 }
3694
3695 $hook_dynamic = has_action('ws_plugin__s2member_during_js_w_globals');
3696 $hook_dynamic = (bool)apply_filters('ws_plugin__s2member_dynamic_js_required', $hook_dynamic, get_defined_vars());
3697 if($hook_dynamic && !$page_text && self::static_js_builtin_pro_callbacks_supported())
3698 $hook_dynamic = FALSE; //260906.2049 Older Pro releases can still use static-file text when their JavaScript hook contains only known built-ins.
3699
3700 if($framework_dynamic || $hook_dynamic)
3701 {
3702 //260913.2111 Separate page-specific values that JavaScript Text Delivery can move into the page from requirements that still need Full WordPress Dynamic JS.
3703 $text_delivery_reasons = array();
3704 $other_reasons = array();
3705 if($js_api_constants_enabled)
3706 $other_reasons[] = 'full s2Member JavaScript API constants are enabled by the "ws_plugin__s2member_js_api_constants_enable" filter';
3707 if(has_action('ws_plugin__s2member_before_js_w_globals'))
3708 $other_reasons[] = 'the "ws_plugin__s2member_before_js_w_globals" hook has a customization';
3709 if(!$page_text && isset($current_locale, $site_locale) && $current_locale !== $site_locale)
3710 $text_delivery_reasons[] = 'the current page language differs from the site default ('.$current_locale.' vs '.$site_locale.')';
3711 foreach(array(
3712 'ws_plugin__s2member_files_dir' => 'the s2Member files directory is generated dynamically',
3713 'ws_plugin__s2member_min_password_length' => 'the minimum password length is generated dynamically',
3714 'ws_plugin__s2member_min_password_strength_code' => 'the password-strength code is generated dynamically',
3715 'ws_plugin__s2member_min_password_strength_score' => 'the password-strength score is generated dynamically',
3716 ) as $filter => $reason)
3717 if(!$page_text && has_filter($filter))
3718 $text_delivery_reasons[] = $reason.' (via hook '.$filter.')';
3719 if(isset($GLOBALS['wp_filter']['all']))
3720 $other_reasons[] = 'WordPress\'s global "all" hook is active';
3721 if(has_filter('ws_plugin__s2member_pro_available_gateways'))
3722 $other_reasons[] = 'the available Pro gateways are filtered dynamically by "ws_plugin__s2member_pro_available_gateways"';
3723 if($hook_dynamic && has_action('ws_plugin__s2member_during_js_w_globals') && !self::static_js_builtin_pro_callbacks_supported())
3724 $other_reasons[] = 'the "ws_plugin__s2member_during_js_w_globals" hook contains a custom, reordered, or unsupported callback';
3725 if($hook_dynamic && !$text_delivery_reasons && !$other_reasons)
3726 $other_reasons[] = 'the "ws_plugin__s2member_dynamic_js_required" filter explicitly requires Dynamic JS';
3727
3728 $text_delivery_reasons = array_unique($text_delivery_reasons);
3729 $other_reasons = array_unique($other_reasons);
3730 $reasons = array_merge($text_delivery_reasons, $other_reasons);
3731 $error = 'Static JavaScript requires Dynamic delivery because '.implode('; ', $reasons).'. Full WordPress Dynamic Loader is required so the current hooks, values, and customizations remain available.';
3732 if($text_delivery_reasons && !$other_reasons)
3733 $health_detail = ucfirst(implode('; ', $text_delivery_reasons)).'. To keep the external JavaScript static, set <a href="#ws-plugin--s2member-static-js-text-setting">JavaScript Text Delivery</a> to "Load JavaScript text with each WordPress page".';
3734 else if($text_delivery_reasons && $other_reasons)
3735 $health_detail = 'Some page-specific JavaScript values require Dynamic JS: '.implode('; ', $text_delivery_reasons).'. Setting <a href="#ws-plugin--s2member-static-js-text-setting">JavaScript Text Delivery</a> to "Load JavaScript text with each WordPress page" lets pages affected only by those values keep using Static JS. Pages where another detected requirement applies will still use Full WordPress Dynamic JS: '.implode('; ', $other_reasons).'.';
3736 else
3737 $health_detail = 'Static JavaScript requires Dynamic delivery because '.implode('; ', $other_reasons).'. Full WordPress Dynamic Loader is used so the required hooks and customizations remain available.';
3738 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => $error, 'health_detail' => $health_detail, 'dynamic_required' => TRUE);
3739 }
3740 if($page_text)
3741 {
3742 $data_map_signature = self::static_js_data_map_signature($id);
3743 if(empty($data_map_signature['ok']))
3744 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => (string)$data_map_signature['error']);
3745 }
3746 if(!$include_sources)
3747 return array('ok' => TRUE, 'sources' => array(), 'minify' => !empty($o['static_js_minify']), 'error' => '');
3748
3749 $sources = ($pro_file) ? array() : array(
3750 array('file' => $c['dir'].'/src/includes/jquery/jquery.sprintf/jquery.sprintf.js', 'preserve_header' => TRUE),
3751 ($page_text)
3752 ? array('file' => $c['dir'].'/src/includes/s2member.js', 'data_map' => $c['dir'].'/src/includes/s2member.js.php', 'data_key' => 'f')
3753 : array('file' => $c['dir'].'/src/includes/s2member.js', 'render' => TRUE),
3754 );
3755 if(!$pro_file)
3756 $sources = (array)apply_filters('ws_plugin__s2member_static_js_sources', $sources, get_defined_vars());
3757 if($pro_file || $combine)
3758 {
3759 $source_count = count($sources);
3760 $sources = (array)apply_filters('ws_plugin__s2member_static_pro_js_sources', $sources, get_defined_vars());
3761 if(!$page_text && c_ws_plugin__s2member_utils_conds::pro_is_installed() && count($sources) === $source_count)
3762 {
3763 $pro_output = self::static_js_builtin_pro_output();
3764 if($pro_output === '')
3765 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'No compatible static JavaScript source is available from the installed s2Member Pro version');
3766 $sources[] = array('contents' => $pro_output);
3767 }
3768 }
3769 if(!$sources)
3770 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'No static JavaScript sources are available for '.$id);
3771 return array('ok' => TRUE, 'sources' => $sources, 'minify' => !empty($o['static_js_minify']), 'error' => '');
3772 }
3773 return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Invalid static asset type');
3774 }
3775
3776 /**
3777 * Replaces marked PHP interpolations in a canonical JavaScript source with compact data-map slots.
3778 *
3779 * The current canonical sources place every marked interpolation inside a single-quoted JavaScript
3780 * string. Replacing only the PHP block with `'+d[n]+'` preserves that historical string coercion.
3781 *
3782 * @package s2Member\Utilities
3783 * @since 260906.0738
3784 *
3785 * @param string $source Canonical mixed JS/PHP source.
3786 * @param string $data_map_path Shipped data-map path.
3787 * @param string $data_key Browser namespace key (`f` or `p`).
3788 * @return array Transform result.
3789 */
3790 protected static function static_js_data_source($source = '', $data_map_path = '', $data_key = '')
3791 {
3792 $data_map = self::static_js_data_map($data_map_path);
3793 if(empty($data_map['ok']))
3794 return array('ok' => FALSE, 'source' => '', 'error' => (string)$data_map['error']);
3795 if(!preg_match('/^[a-z][a-z0-9_]*$/i', (string)$data_key))
3796 return array('ok' => FALSE, 'source' => '', 'error' => 'Invalid JavaScript data namespace key');
3797 $slots = $data_map['slots'];
3798 $errors = array();
3799 $source = preg_replace_callback('/<\\?php.*?\\?>/s', function($match) use ($slots, &$errors) {
3800 if(!preg_match('/\\/\\*d\\*\\/(.*?)\\/\\*b\\*\\//s', $match[0], $data_match))
3801 {
3802 $errors[] = 'An unmarked PHP interpolation remains in a static JavaScript data source';
3803 return $match[0];
3804 }
3805 $expression = trim((string)$data_match[1]);
3806 if(!isset($slots[$expression]))
3807 {
3808 $errors[] = 'A marked JavaScript expression is missing from its shipped data map';
3809 return $match[0];
3810 }
3811 return "'+d[".(int)$slots[$expression]."]+'";
3812 }, (string)$source);
3813 if($errors || strpos($source, '<?php') !== FALSE || strpos($source, '?>') !== FALSE)
3814 return array('ok' => FALSE, 'source' => '', 'error' => ($errors) ? implode('; ', array_unique($errors)) : 'PHP remained after static JavaScript data transformation');
3815 //260906.0738 Keep the short alias lexical to this source so Framework and Pro slots cannot overwrite one another in separate or combined files.
3816 return array('ok' => TRUE, 'source' => "(function(d){\n".$source."\n})(window.s2_data.".$data_key.");", 'error' => '');
3817 }
3818
3819 /**
3820 * Includes one trusted shipped static JavaScript data map in normal WordPress page context.
3821 *
3822 * @package s2Member\Utilities
3823 * @since 260906.0738
3824 *
3825 * @param string $path Data-map path.
3826 * @param array|null $keys Optional stable slot IDs; NULL evaluates every value in the data map.
3827 * @return array|false Data-map values, or FALSE on failure.
3828 */
3829 protected static function load_static_js_data_map($path = '', $keys = NULL)
3830 {
3831 if(!$path || !is_readable($path))
3832 return FALSE;
3833 $s2_data_keys = (is_array($keys)) ? array_fill_keys(array_map('intval', $keys), TRUE) : NULL;
3834 $data = include $path;
3835 return (is_array($data)) ? $data : FALSE;
3836 }
3837
3838 /**
3839 * Returns page-local JavaScript data for the active generated static files.
3840 *
3841 * Complete data maps are emitted for now. TO-DO: pass page-specific sparse slot sets once feature requirements can be determined safely.
3842 *
3843 * @package s2Member\Utilities
3844 * @since 260906.0738
3845 *
3846 * @param array $assets Active generated JavaScript assets keyed by logical filename.
3847 * @return string Inline JavaScript, or an empty string when data-map loading fails.
3848 */
3849 public static function static_js_inline_data($assets = array())
3850 {
3851 if(self::static_js_text_delivery() !== 'page')
3852 return '';
3853 $paths = array();
3854 foreach(array_keys((array)$assets) as $id)
3855 foreach(self::static_js_data_map_paths($id) as $key => $path)
3856 $paths[$key] = $path;
3857 if(!$paths)
3858 return '';
3859
3860 $data = array();
3861 foreach($paths as $key => $path)
3862 {
3863 $data_map = self::load_static_js_data_map($path);
3864 if($data_map === FALSE)
3865 return '';
3866 $data[$key] = $data_map;
3867 }
3868 $json = wp_json_encode($data);
3869 if(!is_string($json) || $json === '')
3870 return '';
3871 //260906.0738 wp_add_inline_script() prints this in HTML; neutralize user-translatable closing-script sequences just like existing inline current-user globals.
3872 $inline = 'window.s2_data='.str_ireplace('</', '<\\/', $json).';';
3873 $extra = (string)apply_filters('ws_plugin__s2member_static_js_inline_globals', '', $assets, get_defined_vars());
3874 $extra = str_ireplace('</', '<\\/', $extra); //260906.0738 Pro gateway globals may contain translated text too, so apply the same closing-script protection.
3875 return $inline.(($extra !== '') ? "\n".$extra : '');
3876 }
3877
3878 /**
3879 * Formats one preserved source notice compactly without turning it into an unreadable single line.
3880 *
3881 * @package s2Member\Utilities
3882 * @since 260905.0106
3883 *
3884 * @param string $comment Original leading source docblock.
3885 * @return string Compact readable preserved notice.
3886 */
3887 protected static function preserved_asset_header($comment = '')
3888 {
3889 $header = trim((string)$comment);
3890 $header = preg_replace('/\A\/\*\*|\*\/\z/', '', $header);
3891 $header = preg_replace('/^\s*\*\s?/m', '', $header);
3892 $header = preg_replace('/\s+/', ' ', trim($header));
3893 $header = str_replace(array('©', '©'), '(c)', $header);
3894 return "/*!\n * ".wordwrap($header, 140, "\n * ", FALSE)."\n */";
3895 }
3896
3897 /**
3898 * Prunes stale generated generations after a successful build.
3899 *
3900 * Keep current build files protected, retain up to ten older generations for cached HTML,
3901 * and remove anything older than 30 days. This bounds normal disk use without deleting the
3902 * previous timestamp immediately after a refresh.
3903 *
3904 * @package s2Member\Utilities
3905 * @since 260905.0106
3906 *
3907 * @param string $dir Current generated-asset directory.
3908 * @param string $new_path Newly generated file path that must be preserved.
3909 * @return null
3910 */
3911 protected static function prune_static_asset_generations($dir = '', $new_path = '')
3912 {
3913 $dir = rtrim((string)$dir, '/\\');
3914 if(!$dir || !is_dir($dir))
3915 return;
3916 $protected = array();
3917 $builds = get_option('ws_plugin__s2member_static_asset_builds', array());
3918 if(is_array($builds))
3919 foreach($builds as $id => $build)
3920 if(in_array($id, array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js'), TRUE) && ($build = abs((int)$build)))
3921 {
3922 $type = substr(strrchr($id, '.'), 1);
3923 $base = substr($id, 0, -strlen('.'.$type));
3924 $protected[$dir.'/'.$base.'-'.$build.'.'.$type] = TRUE;
3925 }
3926 if($new_path)
3927 $protected[(string)$new_path] = TRUE;
3928
3929 $groups = array();
3930 foreach(array('css', 'js') as $type)
3931 foreach((array)glob($dir.'/s2member*.'.$type) as $path)
3932 if(preg_match('/\/(s2member(?:-pro)?)-\d+\.(css|js)\z/', str_replace('\\', '/', $path), $match))
3933 $groups[$match[1].'.'.$match[2]][] = $path;
3934
3935 $removed = array(); //260907.2203 Collect only files actually removed so cleanup logging stays accurate.
3936
3937 foreach($groups as $paths)
3938 {
3939 usort($paths, function($a, $b) {
3940 return (int)@filemtime($b) - (int)@filemtime($a);
3941 });
3942 $stale_kept = 0;
3943 foreach($paths as $old_path)
3944 {
3945 if(isset($protected[$old_path]))
3946 continue;
3947 $stale_kept++;
3948 if((int)@filemtime($old_path) < time() - 30 * DAY_IN_SECONDS || $stale_kept > 10)
3949 if(@unlink($old_path))
3950 $removed[] = basename($old_path);
3951 }
3952 }
3953
3954 //260907.2203 Record cleanup only when stale files were actually deleted.
3955 if($removed)
3956 c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'Stale Static CSS/JS files pruned', 'result' => 'success', 'removed' => $removed));
3957
3958 return;
3959 }
3960
3961 /**
3962 * Builds one timestamped CSS/JS file in the WordPress uploads tree.
3963 *
3964 * @package s2Member\Utilities
3965 * @since 260903.0525
3966 *
3967 * @param string $id Stable asset identifier.
3968 * @param int $build Timestamp used in the generated filename.
3969 * @param string $type `css` or `js`.
3970 * @param array $sources Ordered source definitions/files.
3971 * @param bool $minify Whether generated output should be minified.
3972 * @return array Build result with URL/path or error.
3973 */
3974 protected static function build_static_asset($id = '', $build = 0, $type = '', $sources = array(), $minify = FALSE)
3975 {
3976 $id = trim(preg_replace('/[^a-z0-9_\-]/i', '-', (string)$id), '-');
3977 $type = strtolower((string)$type);
3978 if(!$id || !$build || !in_array($type, array('css', 'js'), TRUE) || !$sources)
3979 return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'Invalid generated asset parameters');
3980
3981 $location = self::static_assets_location(TRUE);
3982 if(empty($location['ok']))
3983 return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => $location['error']);
3984 $filename = $id.'-'.(int)$build.'.'.$type;
3985 $path = $location['dir'].'/'.$filename;
3986 $url = $location['url'].'/'.$filename;
3987 $headers = array();
3988 $body = '';
3989 foreach($sources as $source)
3990 {
3991 $source = is_array($source) ? $source : array('file' => $source);
3992 if(array_key_exists('contents', $source))
3993 $chunk = (string)$source['contents'];
3994 else if(empty($source['file']) || !is_readable($source['file']))
3995 return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'Source file is not readable: '.((!empty($source['file'])) ? $source['file'] : '(missing path)'));
3996 else if(!empty($source['data_map']))
3997 {
3998 if(($chunk = file_get_contents($source['file'])) === FALSE)
3999 return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'Could not read source file: '.$source['file']);
4000 $transformed = self::static_js_data_source($chunk, (string)$source['data_map'], (!empty($source['data_key'])) ? (string)$source['data_key'] : '');
4001 if(empty($transformed['ok']))
4002 return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => (string)$transformed['error'].' in '.$source['file']);
4003 $chunk = $transformed['source'];
4004 }
4005 else if(!empty($source['render']))
4006 {
4007 $template_vars = (!empty($source['vars']) && is_array($source['vars'])) ? $source['vars'] : array();
4008 extract($template_vars, EXTR_SKIP);
4009 ob_start();
4010 include $source['file'];
4011 $chunk = ob_get_clean();
4012 }
4013 else if(($chunk = file_get_contents($source['file'])) === FALSE)
4014 return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'Could not read source file: '.$source['file']);
4015
4016 if(preg_match('/\A\s*(\/\*\*.*?\*\/)\s*/s', $chunk, $match))
4017 {
4018 if(!empty($source['preserve_header']))
4019 {
4020 //260905.0106 Preserve the complete notice in a compact wrapped block instead of a huge original banner or an unreadable single line.
4021 $header = self::preserved_asset_header($match[1]);
4022 if(!in_array($header, $headers, TRUE))
4023 $headers[] = $header;
4024 }
4025 $chunk = preg_replace('/\A\s*\/\*\*.*?\*\/\s*/s', '', $chunk, 1);
4026 }
4027 if(!empty($source['replacements']) && is_array($source['replacements']))
4028 $chunk = str_replace(array_keys($source['replacements']), array_values($source['replacements']), $chunk);
4029 $body .= "\n".((!empty($source['prefix'])) ? $source['prefix']."\n" : '').$chunk.((!empty($source['suffix'])) ? "\n".$source['suffix'] : '');
4030 }
4031
4032 //260906.2219 Personal/member globals are page-specific by design and must never be written into a publicly cacheable static JavaScript file.
4033 if($type === 'js' && preg_match('/\bS2MEMBER_CURRENT_USER_[A-Z0-9_]+\s*=(?!=)/', $body))
4034 return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'Page-specific member globals cannot be stored in static JavaScript');
4035
4036 try
4037 {
4038 $body = ($minify) ? (($type === 'css') ? self::compress_css($body) : self::compress_js($body)) : trim($body);
4039 }
4040 catch(Exception $e)
4041 {
4042 return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'JavaScript minification failed: '.$e->getMessage());
4043 }
4044 $activation_tag = self::static_activation_tag_snippet($id, $type, $build);
4045 $output = (($headers) ? implode("\n", $headers)."\n" : '').$body."\n".$activation_tag."\n";
4046 $tmp = $path.'.tmp-'.uniqid('', TRUE);
4047 if(file_put_contents($tmp, $output, LOCK_EX) === FALSE || (!@rename($tmp, $path) && !is_file($path)))
4048 {
4049 @unlink($tmp);
4050 return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'Could not write generated asset: '.$path);
4051 }
4052 @unlink($tmp);
4053
4054 return array('ok' => TRUE, 'url' => $url, 'path' => $path, 'error' => '');
4055 }
4056
4057 /**
4058 * Resolves the writable/public directory used for generated static frontend assets.
4059 *
4060 * @package s2Member\Utilities
4061 * @since 260903.0437
4062 *
4063 * @param bool $for_write Create/validate the directory for a write operation.
4064 * @return array Location result.
4065 */
4066 protected static function static_assets_location($for_write = FALSE)
4067 {
4068 $key = ($for_write) ? 'write' : 'read';
4069 if(isset(self::$static_assets_location_cache[$key]))
4070 return self::$static_assets_location_cache[$key];
4071 $uploads = wp_upload_dir(NULL, (bool)$for_write);
4072 if(!empty($uploads['error']) || empty($uploads['basedir']) || empty($uploads['baseurl']))
4073 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'] : ''));
4074
4075 $dir = untrailingslashit((string)apply_filters('ws_plugin__s2member_static_assets_dir', trailingslashit($uploads['basedir']).'s2member-assets', $uploads));
4076 $url = untrailingslashit((string)apply_filters('ws_plugin__s2member_static_assets_url', trailingslashit($uploads['baseurl']).'s2member-assets', $uploads));
4077 if(!$dir || !$url)
4078 return self::$static_assets_location_cache[$key] = array('ok' => FALSE, 'dir' => '', 'url' => '', 'error' => 'The static-assets directory or URL filter returned an empty value');
4079 if($for_write)
4080 {
4081 if(!is_dir($dir) && !wp_mkdir_p($dir))
4082 return self::$static_assets_location_cache[$key] = array('ok' => FALSE, 'dir' => $dir, 'url' => $url, 'error' => 'Could not create static-assets directory: '.$dir);
4083 if(!is_writable($dir))
4084 return self::$static_assets_location_cache[$key] = array('ok' => FALSE, 'dir' => $dir, 'url' => $url, 'error' => 'Static-assets directory is not writable: '.$dir);
4085
4086 //260905.0158 Discourage casual directory listing without adding executable PHP to the public uploads directory.
4087 $index_file = $dir.'/index.html';
4088 if(!is_file($index_file))
4089 @file_put_contents($index_file, '<!-- Silence is golden. -->'."\n", LOCK_EX);
4090 }
4091 return self::$static_assets_location_cache[$key] = array('ok' => TRUE, 'dir' => $dir, 'url' => $url, 'error' => '');
4092 }
4093
4094 /**
4095 * Handles CSS compression of hex colors.
4096 *
4097 * @package s2Member\Utilities
4098 * @since 3.5
4099 *
4100 * @param array $m Array of matches from ``preg_replace_callback()``.
4101 * @return string Shortened hex code when possible, full hex code otherwise.
4102 */
4103 public static function _compress_css_c3($m = FALSE)
4104 {
4105 if($m[2][0] === $m[2][1] && $m[2][2] === $m[2][3] && $m[2][4] === $m[2][5])
4106 return $m[1].$m[2][0].$m[2][2].$m[2][4];
4107 return $m[0];
4108 }
4109
4110 /**
4111 * JShrink 1.8.1 adaptation used for generated JavaScript minification.
4112 *
4113 * JShrink is Copyright (c) Robert Hafner and licensed under BSD-3-Clause.
4114 * See `/src/licensing/jshrink.txt` for the complete license and attribution.
4115 * @see https://github.com/tedious/JShrink
4116 *
4117 * The upstream parser is kept intentionally isolated behind `jshrink_*` names.
4118 * The only PHP 5.6 compatibility change avoids PHP 7.1+ negative string offsets
4119 * when tracking the last character.
4120 *
4121 * @package s2Member\Utilities
4122 * @since 260903.0437
4123 */
4124 protected $jshrink_input;
4125 protected $jshrink_len = 0;
4126 protected $jshrink_index = 0;
4127 protected $jshrink_a = '';
4128 protected $jshrink_b = '';
4129 protected $jshrink_c;
4130 protected $jshrink_last_char;
4131 protected $jshrink_output = '';
4132 protected $jshrink_options = array();
4133 protected $jshrink_string_delimiters = array("'" => TRUE, '"' => TRUE, '`' => TRUE);
4134 protected $jshrink_no_new_line_characters = array('(' => TRUE, '-' => TRUE, '+' => TRUE, '[' => TRUE, '#' => TRUE, '@' => TRUE);
4135 protected static $jshrink_default_options = array('flaggedComments' => TRUE);
4136 protected static $jshrink_keywords = array('delete', 'do', 'for', 'in', 'instanceof', 'return', 'typeof', 'yield');
4137 protected $jshrink_max_keyword_len = 0;
4138 protected $jshrink_locks = array();
4139
4140 protected function jshrink_minify_to_string($js, $options)
4141 {
4142 $this->jshrink_initialize($js, $options);
4143 $this->jshrink_loop();
4144 $output = $this->jshrink_output;
4145 $this->jshrink_clean();
4146 return $output;
4147 }
4148
4149 protected function jshrink_initialize($js, $options)
4150 {
4151 $this->jshrink_options = array_merge(self::$jshrink_default_options, $options);
4152 $this->jshrink_input = $js.PHP_EOL;
4153 $this->jshrink_len = strlen($this->jshrink_input);
4154 $this->jshrink_a = "\n";
4155 $this->jshrink_b = "\n";
4156 $this->jshrink_last_char = "\n";
4157 $this->jshrink_output = '';
4158 $this->jshrink_max_keyword_len = max(array_map('strlen', self::$jshrink_keywords));
4159 }
4160
4161 protected function jshrink_echo($char)
4162 {
4163 $this->jshrink_output .= $char;
4164 //260903.0437 JShrink 1.8.1 uses `$char[-1]`; `substr()` preserves that behavior on s2Member's PHP 5.6 minimum.
4165 $this->jshrink_last_char = substr($char, -1);
4166 }
4167
4168 protected function jshrink_loop()
4169 {
4170 while($this->jshrink_a !== FALSE && !is_null($this->jshrink_a) && $this->jshrink_a !== '')
4171 {
4172 switch($this->jshrink_a)
4173 {
4174 case "\r":
4175 case "\n":
4176 if($this->jshrink_b !== FALSE && isset($this->jshrink_no_new_line_characters[$this->jshrink_b]))
4177 {
4178 $this->jshrink_echo($this->jshrink_a);
4179 $this->jshrink_save_string();
4180 break;
4181 }
4182 if($this->jshrink_b === ' ')
4183 break;
4184 case ' ':
4185 if(self::jshrink_is_alphanumeric($this->jshrink_b))
4186 $this->jshrink_echo($this->jshrink_a);
4187 $this->jshrink_save_string();
4188 break;
4189 default:
4190 switch($this->jshrink_b)
4191 {
4192 case "\r":
4193 case "\n":
4194 if(strpos('}])+-"\'', $this->jshrink_a) !== FALSE)
4195 {
4196 $this->jshrink_echo($this->jshrink_a);
4197 $this->jshrink_save_string();
4198 break;
4199 }
4200 else if(self::jshrink_is_alphanumeric($this->jshrink_a))
4201 {
4202 $this->jshrink_echo($this->jshrink_a);
4203 $this->jshrink_save_string();
4204 }
4205 break;
4206 case ' ':
4207 if(!self::jshrink_is_alphanumeric($this->jshrink_a))
4208 break;
4209 default:
4210 if($this->jshrink_a === '/' && ($this->jshrink_b === "'" || $this->jshrink_b === '"'))
4211 {
4212 $this->jshrink_save_regex();
4213 continue 3;
4214 }
4215 $this->jshrink_echo($this->jshrink_a);
4216 $this->jshrink_save_string();
4217 break;
4218 }
4219 }
4220
4221 $this->jshrink_b = $this->jshrink_get_real();
4222 if($this->jshrink_b == '/')
4223 {
4224 $valid_tokens = "(,=:[!&|?\n";
4225 $last_token = ($this->jshrink_a == ' ') ? $this->jshrink_last_char : $this->jshrink_a;
4226 if(strpos($valid_tokens, $last_token) !== FALSE || $this->jshrink_ends_in_keyword())
4227 $this->jshrink_save_regex();
4228 }
4229 }
4230 }
4231
4232 protected function jshrink_clean()
4233 {
4234 unset($this->jshrink_input, $this->jshrink_c, $this->jshrink_options);
4235 $this->jshrink_len = $this->jshrink_index = 0;
4236 $this->jshrink_a = $this->jshrink_b = '';
4237 $this->jshrink_output = '';
4238 }
4239
4240 protected function jshrink_get_char()
4241 {
4242 if(isset($this->jshrink_c))
4243 {
4244 $char = $this->jshrink_c;
4245 unset($this->jshrink_c);
4246 }
4247 else
4248 {
4249 $char = ($this->jshrink_index < $this->jshrink_len) ? $this->jshrink_input[$this->jshrink_index] : FALSE;
4250 if($char === FALSE)
4251 return FALSE;
4252 $this->jshrink_index++;
4253 }
4254 if($char == "\r")
4255 $char = "\n";
4256 if($char !== "\n" && $char < "\x20")
4257 return ' ';
4258 return $char;
4259 }
4260
4261 protected function jshrink_peek()
4262 {
4263 if($this->jshrink_index >= $this->jshrink_len)
4264 return FALSE;
4265 $char = $this->jshrink_input[$this->jshrink_index];
4266 if($char == "\r")
4267 $char = "\n";
4268 if($char !== "\n" && $char < "\x20")
4269 return ' ';
4270 return $char;
4271 }
4272
4273 protected function jshrink_get_real()
4274 {
4275 $start_index = $this->jshrink_index;
4276 $char = $this->jshrink_get_char();
4277 if($char !== '/')
4278 return $char;
4279 $this->jshrink_c = $this->jshrink_get_char();
4280 if($this->jshrink_c === '/')
4281 {
4282 $this->jshrink_process_one_line_comments($start_index);
4283 return $this->jshrink_get_real();
4284 }
4285 else if($this->jshrink_c === '*')
4286 {
4287 $this->jshrink_process_multi_line_comments($start_index);
4288 return $this->jshrink_get_real();
4289 }
4290 return $char;
4291 }
4292
4293 protected function jshrink_process_one_line_comments($start_index)
4294 {
4295 $third = ($this->jshrink_index < $this->jshrink_len) ? $this->jshrink_input[$this->jshrink_index] : FALSE;
4296 $this->jshrink_get_next("\n");
4297 unset($this->jshrink_c);
4298 if($third == '@')
4299 {
4300 $end = $this->jshrink_index - $start_index;
4301 $this->jshrink_c = "\n".substr($this->jshrink_input, $start_index, $end);
4302 }
4303 }
4304
4305 protected function jshrink_process_multi_line_comments($start_index)
4306 {
4307 $this->jshrink_get_char();
4308 $third = $this->jshrink_get_char();
4309 if($third == '*' && $this->jshrink_peek() == '/')
4310 {
4311 $this->jshrink_index++;
4312 return;
4313 }
4314 if($this->jshrink_get_next('*/'))
4315 {
4316 $this->jshrink_get_char();
4317 $this->jshrink_get_char();
4318 $char = $this->jshrink_get_char();
4319 if((!empty($this->jshrink_options['flaggedComments']) && $third === '!') || $third === '@')
4320 {
4321 if($start_index > 0)
4322 {
4323 $this->jshrink_echo($this->jshrink_a);
4324 $this->jshrink_a = ' ';
4325 if($this->jshrink_input[$start_index - 1] === "\n")
4326 $this->jshrink_echo("\n");
4327 }
4328 $end = ($this->jshrink_index - 1) - $start_index;
4329 $this->jshrink_echo(substr($this->jshrink_input, $start_index, $end));
4330 $this->jshrink_c = $char;
4331 return;
4332 }
4333 }
4334 else
4335 $char = FALSE;
4336 if($char === FALSE)
4337 throw new RuntimeException('Unclosed multiline comment at position: '.($this->jshrink_index - 2));
4338 $this->jshrink_c = $char;
4339 }
4340
4341 protected function jshrink_get_next($string)
4342 {
4343 $pos = strpos($this->jshrink_input, $string, $this->jshrink_index);
4344 if($pos === FALSE)
4345 return FALSE;
4346 $this->jshrink_index = $pos;
4347 return ($this->jshrink_index < $this->jshrink_len) ? $this->jshrink_input[$this->jshrink_index] : FALSE;
4348 }
4349
4350 protected function jshrink_save_string()
4351 {
4352 $start = $this->jshrink_index;
4353 $this->jshrink_a = $this->jshrink_b;
4354 if(!isset($this->jshrink_string_delimiters[$this->jshrink_a]))
4355 return;
4356 $type = $this->jshrink_a;
4357 $this->jshrink_echo($this->jshrink_a);
4358 while(($this->jshrink_a = $this->jshrink_get_char()) !== FALSE)
4359 {
4360 switch($this->jshrink_a)
4361 {
4362 case $type:
4363 break 2;
4364 case "\n":
4365 if($type === '`')
4366 $this->jshrink_echo($this->jshrink_a);
4367 else
4368 throw new RuntimeException('Unclosed string at position: '.$start);
4369 break;
4370 case '\\':
4371 $this->jshrink_b = $this->jshrink_get_char();
4372 if($this->jshrink_b !== "\n")
4373 $this->jshrink_echo($this->jshrink_a.$this->jshrink_b);
4374 break;
4375 default:
4376 $this->jshrink_echo($this->jshrink_a);
4377 }
4378 }
4379 }
4380
4381 protected function jshrink_save_regex()
4382 {
4383 if($this->jshrink_a != ' ')
4384 $this->jshrink_echo($this->jshrink_a);
4385 $this->jshrink_echo($this->jshrink_b);
4386 $character_class = FALSE;
4387 $character_class_index = NULL;
4388 while(($this->jshrink_a = $this->jshrink_get_char()) !== FALSE)
4389 {
4390 if($this->jshrink_a === '/' && !$character_class)
4391 break;
4392 if($this->jshrink_a === '[')
4393 {
4394 $character_class = TRUE;
4395 $character_class_index = $this->jshrink_index;
4396 }
4397 else if($this->jshrink_a === ']')
4398 $character_class = FALSE;
4399 if($this->jshrink_a === '\\')
4400 {
4401 $this->jshrink_echo($this->jshrink_a);
4402 $this->jshrink_a = $this->jshrink_get_char();
4403 }
4404 if($this->jshrink_a === "\n")
4405 {
4406 if($character_class)
4407 throw new RuntimeException('Unclosed character class at position: '.$character_class_index);
4408 throw new RuntimeException('Unclosed regex pattern at position: '.$this->jshrink_index);
4409 }
4410 $this->jshrink_echo($this->jshrink_a);
4411 }
4412 $this->jshrink_b = $this->jshrink_get_real();
4413 }
4414
4415 protected static function jshrink_is_alphanumeric($char)
4416 {
4417 return preg_match('/^[\w\$\pL]$/', $char) === 1 || $char == '/';
4418 }
4419
4420 protected function jshrink_ends_in_keyword()
4421 {
4422 $test = substr($this->jshrink_output.$this->jshrink_a, -1 * ($this->jshrink_max_keyword_len + 10));
4423 foreach(self::$jshrink_keywords as $keyword)
4424 if(preg_match('/[^\w]'.$keyword.'[ ]?$/i', $test) === 1)
4425 return TRUE;
4426 return FALSE;
4427 }
4428
4429 protected function jshrink_lock($js)
4430 {
4431 $lock = '"LOCK---'.crc32(time()).'"';
4432 $matches = array();
4433 preg_match('/([+-])(\s+)([+-])/S', $js, $matches);
4434 if(empty($matches))
4435 return $js;
4436 $this->jshrink_locks[$lock] = $matches[2];
4437 return preg_replace('/([+-])\s+([+-])/S', '$1'.$lock.'$2', $js);
4438 }
4439
4440 protected function jshrink_unlock($js)
4441 {
4442 foreach($this->jshrink_locks as $lock => $replacement)
4443 $js = str_replace($lock, $replacement, $js);
4444 return $js;
4445 }
4446 }
4447 }
4448