| \>|\> /", ">", $css); $css = preg_replace("/\[ /", "[", $css); $css = preg_replace("/ \]/", "]", $css); $css = preg_replace("/ \!\= | \!\=|\!\= /", "!=", $css); $css = preg_replace("/ \|\= | \|\=|\|\= /", "|=", $css); $css = preg_replace("/ \^\= | \^\=|\^\= /", "^=", $css); $css = preg_replace("/ \$\= | \$\=|\$\= /", "$=", $css); $css = preg_replace("/ \*\= | \*\=|\*\= /", "*=", $css); $css = preg_replace("/ ~\= | ~\=|~\= /", "~=", $css); $css = preg_replace("/ \= | \=|\= /", "=", $css); $css = preg_replace("/ \+ | \+|\+ /", "+", $css); $css = preg_replace("/ ~ | ~|~ /", "~", $css); $css = preg_replace("/ \{ | \{|\{ /", "{", $css); $css = preg_replace("/ \} | \}|\} /", "}", $css); $css = preg_replace("/ \: | \:|\: /", ":", $css); $css = preg_replace("/ ; | ;|; /", ";", $css); $css = preg_replace("/;\}/", "}", $css); return preg_replace_callback($c6, 'c_ws_plugin__s2member_utils_assets::_compress_css_c3', $css); } /** * Compresses JavaScript using an s2Member-adapted implementation of JShrink 1.8.1. * * JShrink is Copyright (c) Robert Hafner and licensed under BSD-3-Clause. * See `/src/licensing/jshrink.txt` for the complete license and attribution. * @see https://github.com/tedious/JShrink * * @package s2Member\Utilities * @since 260903.0437 * * @param string $js JavaScript source. * @return string Minified JavaScript. * @throws RuntimeException If malformed JavaScript cannot be minified safely. */ public static function compress_js($js = '') { $minifier = new self(); try { $js = $minifier->jshrink_lock((string)$js); $js = ltrim($minifier->jshrink_minify_to_string($js, array('flaggedComments' => TRUE))); $js = $minifier->jshrink_unlock($js); $minifier->jshrink_clean(); return $js; } catch(Exception $e) { $minifier->jshrink_clean(); throw $e; } } /** * Returns the selected URL used whenever frontend CSS/JavaScript needs dynamic generation. * * 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. * * @package s2Member\Utilities * @since 260904.0221 * * @param bool $force_wordpress Force the full WordPress route for a compatibility fallback. * @return string Dynamic frontend asset URL without query arguments. */ public static function dynamic_asset_url($force_wordpress = FALSE) { if(!$force_wordpress && (empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress') && is_file(self::s2o_file_path()) && !self::asset_http_target_failed('s2o', $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'])) return $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url']; return self::wordpress_dynamic_asset_url(); } /** * Returns the normal WordPress front-controller URL for dynamic frontend assets. * * @package s2Member\Utilities * @since 260904.2110 * * @return string WordPress dynamic frontend asset URL without query arguments. */ protected static function wordpress_dynamic_asset_url() { global $wp_rewrite; $index = (is_object($wp_rewrite) && !empty($wp_rewrite->index)) ? ltrim((string)$wp_rewrite->index, '/') : ''; if($index === '') $index = 'index.php'; return home_url('/'.$index); } /** * Returns the local s2member-o.php path. * * @package s2Member\Utilities * @since 260904.2110 * * @return string Local filesystem path. */ protected static function s2o_file_path() { return $GLOBALS['WS_PLUGIN__']['s2member']['c']['dir'].'/'.preg_replace('/\.php$/', '-o.php', basename($GLOBALS['WS_PLUGIN__']['s2member']['l'])); } /** * Returns true when a trusted browser probe has confirmed that one current asset URL is unreachable. * * @package s2Member\Utilities * @since 260904.2110 * * @param string $id Logical health target ID. * @param string $url Current public asset URL. * @return bool True when the exact current URL has a recorded failure. */ protected static function asset_http_target_failed($id = '', $url = '') { $health = self::asset_http_health_state(); return !empty($health['failures'][$id]['url']) && (string)$health['failures'][$id]['url'] === (string)$url; } /** * Returns the browser-reported frontend asset health state once per PHP request. * * @package s2Member\Utilities * @since 260904.2110 * * @return array Stored HTTP health state. */ protected static function asset_http_health_state() { if(!isset(self::$asset_http_health_cache)) { $health = get_option('ws_plugin__s2member_asset_http_health', array()); self::$asset_http_health_cache = (is_array($health)) ? $health : array(); } return self::$asset_http_health_cache; } /** * Returns recent low-trust runtime suspicions reported by real frontend pages. * * Reports are only hints. They never change delivery by themselves. A trusted administrator-browser probe must confirm the exact URL/marker before persistent fallback or a confirmed notice is used. * * @package s2Member\Utilities * @since 260904.2255 * * @return array Current runtime suspicions. */ protected static function asset_runtime_suspicions() { $suspicions = get_option('ws_plugin__s2member_asset_runtime_suspicions', array()); $suspicions = (is_array($suspicions)) ? $suspicions : array(); foreach($suspicions as $key => $suspicion) if(empty($suspicion['reported']) || (int)$suspicion['reported'] < time() - HOUR_IN_SECONDS) unset($suspicions[$key]); return $suspicions; } /** * Returns the current public asset URLs that an administrator's browser should probe. * * Normal checks are deliberately cheap. Static files use HEAD and s2member-o.php has a special early health response that exits before loading WordPress. A real-page suspicion adds a one-time full marker check for the exact asset that page expected. * * @package s2Member\Utilities * @since 260904.2110 * * @return array Health targets keyed by logical target ID. */ protected static function asset_http_health_targets() { $targets = array(); if((empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress') && is_file(self::s2o_file_path())) $targets['s2o'] = array( 'id' => 's2o', 'url' => $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'], 'probe_url' => add_query_arg('s2member_health_check', '1', $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url']), 'type' => 'health', 'mode' => 's2o-health', 'label' => 's2Member Dynamic Loader', 'failure_id' => 's2o', 'failure_url' => $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url'], ); $location = self::static_assets_location(FALSE); if(!empty($location['ok'])) foreach(array('css' => 'static_css', 'js' => 'static_js') as $type => $option) if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o'][$option])) foreach(self::static_asset_ids($type, 'all') as $id) { $build = self::static_asset_build($id); if($build <= 0) continue; $base = substr($id, 0, -strlen('.'.$type)); $url = $location['url'].'/'.$base.'-'.$build.'.'.$type; if(is_file($location['dir'].'/'.$base.'-'.$build.'.'.$type)) $targets['static:'.$id] = array( 'id' => 'static:'.$id, 'url' => $url, 'probe_url' => $url, 'type' => $type, 'mode' => 'head', 'label' => $id, 'failure_id' => 'static:'.$id, 'failure_url' => $url, ); } foreach(self::asset_runtime_suspicions() as $key => $suspicion) { if(!self::asset_runtime_expectation_is_current($suspicion)) continue; $id = 'runtime:'.$key; $failure_id = ''; $failure_url = (string)$suspicion['url']; if($suspicion['delivery'] === 'dynamic-lightweight') { $failure_id = 's2o'; $failure_url = $GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url']; } else if($suspicion['delivery'] === 'static' && !empty($suspicion['asset_id'])) $failure_id = 'static:'.$suspicion['asset_id']; else $failure_id = $id; $targets[$id] = array( 'id' => $id, 'url' => (string)$suspicion['url'], 'probe_url' => (string)$suspicion['url'], 'type' => (string)$suspicion['type'], 'mode' => 'marker', 'label' => (string)$suspicion['id'], 'markers' => array((string)$suspicion['marker']), 'failure_id' => $failure_id, 'failure_url' => $failure_url, 'suspicion_key' => $key, 'suspicion' => $suspicion, ); } return $targets; } /** * Returns a stable hash for the current browser health targets. * * @package s2Member\Utilities * @since 260904.2110 * * @param array $targets Current health targets. * @return string Target hash. */ protected static function asset_http_health_target_hash($targets = array()) { $hash = array(); foreach((array)$targets as $id => $target) $hash[$id] = array((string)$target['url'], (string)$target['type'], (string)$target['mode'], (!empty($target['markers'])) ? array_values((array)$target['markers']) : array()); return md5(serialize($hash)); } /** * Returns marker output appended to a generated static asset. * * @package s2Member\Utilities * @since 260904.2255 * * @param string $id Stable generated asset identifier without extension. * @param string $type `css` or `js`. * @param int $build Generated build timestamp. * @return string Marker output. */ protected static function static_asset_marker_output($id = '', $type = '', $build = 0) { $components = array(); if($id === 's2member-pro') $components[] = 'pro'; else { $components[] = 'framework'; if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_assets_combine']) && (defined('WS_PLUGIN__S2MEMBER_PRO_VERSION') || isset($GLOBALS['WS_PLUGIN__']['s2member_pro']))) $components[] = 'pro'; } $token = 'static-'.(int)$build; if($type === 'css') { $markers = array(); foreach($components as $component) $markers[] = '#ws-plugin--s2member-'.$component.'-css-health{z-index:'.(($component === 'framework') ? '2147483641' : '2147483642').'!important}'; return implode('', $markers); } $markers = ';window.ws_plugin__s2member_asset_health=window.ws_plugin__s2member_asset_health||{};'; foreach($components as $component) $markers .= 'window.ws_plugin__s2member_asset_health["'.$component.'_js"]="'.$token.'";'; return $markers; } /** * Returns marker output appended to dynamically generated CSS or JavaScript. * * @package s2Member\Utilities * @since 260904.2255 * * @param string $type `css` or `js`. * @return string Marker output. */ public static function dynamic_asset_marker_output($type = '') { $type = strtolower((string)$type); if(!in_array($type, array('css', 'js'), TRUE)) return ''; $token = (defined('_WS_PLUGIN__S2MEMBER_ONLY')) ? 'dynamic-lightweight' : 'dynamic-wordpress'; $components = array('framework'); if(defined('WS_PLUGIN__S2MEMBER_PRO_VERSION') || isset($GLOBALS['WS_PLUGIN__']['s2member_pro'])) $components[] = 'pro'; if($type === 'css') { $markers = array(); foreach($components as $component) $markers[] = '#ws-plugin--s2member-'.$component.'-css-health{z-index:'.(($component === 'framework') ? '2147483641' : '2147483642').'!important}'; return "\n".implode('', $markers)."\n"; } $markers = "\n;window.ws_plugin__s2member_asset_health=window.ws_plugin__s2member_asset_health||{};"; foreach($components as $component) $markers .= 'window.ws_plugin__s2member_asset_health["'.$component.'_js"]="'.$token.'";'; return $markers."\n"; } /** * Registers the exact CSS/JavaScript markers expected on the current frontend page. * * @package s2Member\Utilities * @since 260904.2255 * * @param string $asset_id Logical static asset ID, or an empty string for dynamic delivery. * @param string $type `css` or `js`. * @param string $url Public URL emitted on this page. * @param string $delivery `static`, `dynamic-lightweight`, or `dynamic-wordpress`. * @param int $build Static build timestamp, or zero for dynamic delivery. * @return null */ public static function register_page_asset_expectations($asset_id = '', $type = '', $url = '', $delivery = '', $build = 0) { $type = strtolower((string)$type); $url = (string)$url; if(!in_array($type, array('css', 'js'), TRUE) || !$url) return; $components = array(); if($delivery === 'static') { if(strpos((string)$asset_id, 's2member-pro.') === 0) $components[] = 'pro'; else { $components[] = 'framework'; if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_assets_combine']) && (defined('WS_PLUGIN__S2MEMBER_PRO_VERSION') || isset($GLOBALS['WS_PLUGIN__']['s2member_pro']))) $components[] = 'pro'; } $token = 'static-'.(int)$build; } else { $components[] = 'framework'; if(defined('WS_PLUGIN__S2MEMBER_PRO_VERSION') || isset($GLOBALS['WS_PLUGIN__']['s2member_pro'])) $components[] = 'pro'; $token = ($delivery === 'dynamic-lightweight') ? 'dynamic-lightweight' : 'dynamic-wordpress'; } $recovery_url = ''; if($delivery !== 'dynamic-wordpress') { if($type === 'css') $recovery_url = add_query_arg(array('ws_plugin__s2member_css' => '1', 'qcABC' => '1'), self::wordpress_dynamic_asset_url()); else { $js_value = (is_user_logged_in() && defined('WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5')) ? WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5 : '1'; $recovery_url = add_query_arg(array('ws_plugin__s2member_js_w_globals' => $js_value, 'qcABC' => '1'), self::wordpress_dynamic_asset_url()); } } foreach($components as $component) { $id = $component.'_'.$type; if($type === 'css') { $token = ($component === 'framework') ? '2147483641' : '2147483642'; $marker = '#ws-plugin--s2member-'.$component.'-css-health{z-index:'.$token.'!important}'; } else $marker = 'ws_plugin__s2member_asset_health["'.$component.'_js"]="'.$token.'"'; $expectation = array( 'id' => $id, 'asset_id' => (string)$asset_id, 'type' => $type, 'component' => $component, 'url' => $url, 'delivery' => $delivery, 'token' => $token, 'marker' => $marker, 'recovery_url' => $recovery_url, ); $expectation['signature'] = self::asset_runtime_expectation_signature($expectation); self::$page_asset_expectations[$id] = $expectation; } return; } /** * Expands one compact browser runtime expectation into the full signed structure. * * Frontend pages only need a few fields to check markers and recover. Reconstruct the * descriptive fields here when a miss is actually reported, keeping healthy page source small. * * @package s2Member\Utilities * @since 260905.0106 * * @param array $compact Compact expectation fields. * @return array Full expectation, or an empty array when invalid. */ protected static function expand_asset_runtime_expectation($compact = array()) { if(!is_array($compact) || count($compact) < 6) return array(); $id = isset($compact[0]) ? (string)$compact[0] : ''; $asset_id = isset($compact[1]) ? (string)$compact[1] : ''; $url = isset($compact[2]) ? (string)$compact[2] : ''; $delivery = isset($compact[3]) ? (string)$compact[3] : ''; $token = isset($compact[4]) ? (string)$compact[4] : ''; $signature = isset($compact[5]) ? (string)$compact[5] : ''; if(!preg_match('/\A(framework|pro)_(css|js)\z/', $id, $match)) return array(); $component = $match[1]; $type = $match[2]; if($type === 'css') $marker = '#ws-plugin--s2member-'.$component.'-css-health{z-index:'.$token.'!important}'; else $marker = 'ws_plugin__s2member_asset_health["'.$component.'_js"]="'.$token.'"'; $recovery_url = ''; if($delivery !== 'dynamic-wordpress') { if($type === 'css') $recovery_url = add_query_arg(array('ws_plugin__s2member_css' => '1', 'qcABC' => '1'), self::wordpress_dynamic_asset_url()); else { $js_value = (is_user_logged_in() && defined('WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5')) ? WS_PLUGIN__S2MEMBER_API_CONSTANTS_MD5 : '1'; $recovery_url = add_query_arg(array('ws_plugin__s2member_js_w_globals' => $js_value, 'qcABC' => '1'), self::wordpress_dynamic_asset_url()); } } return array( 'id' => $id, 'asset_id' => $asset_id, 'type' => $type, 'component' => $component, 'url' => $url, 'delivery' => $delivery, 'token' => $token, 'marker' => $marker, 'recovery_url' => $recovery_url, 'signature' => $signature, ); } /** * Returns a signature for one low-trust runtime expectation report. * * @package s2Member\Utilities * @since 260904.2255 * * @param array $expectation Runtime expectation fields. * @return string Signature. */ protected static function asset_runtime_expectation_signature($expectation = array()) { $parts = array(); foreach(array('id', 'asset_id', 'type', 'component', 'url', 'delivery', 'token', 'marker', 'recovery_url') as $key) $parts[$key] = isset($expectation[$key]) ? (string)$expectation[$key] : ''; return hash_hmac('sha256', serialize($parts), wp_salt('nonce')); } /** * Returns true when a reported expectation still describes the site's current delivery state. * * @package s2Member\Utilities * @since 260904.2255 * * @param array $expectation Runtime expectation. * @return bool True when current. */ protected static function asset_runtime_expectation_is_current($expectation = array()) { if(empty($expectation['url']) || empty($expectation['delivery']) || empty($expectation['type'])) return FALSE; if($expectation['delivery'] === 'static') { $id = (string)$expectation['asset_id']; $type = (string)$expectation['type']; if(!in_array($id, self::static_asset_ids($type, 'all'), TRUE)) return FALSE; $build = self::static_asset_build($id); $location = self::static_assets_location(FALSE); $base = substr($id, 0, -strlen('.'.$type)); return !empty($location['ok']) && $build > 0 && (string)$expectation['url'] === $location['url'].'/'.$base.'-'.$build.'.'.$type; } if($expectation['delivery'] === 'dynamic-lightweight') 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; if($expectation['delivery'] === 'dynamic-wordpress') return strpos((string)$expectation['url'], self::wordpress_dynamic_asset_url().'?') === 0; return FALSE; } /** * Returns active generated frontend asset IDs for one type/component. * * Framework and Pro files stay separate by default. Combined mode reuses the Framework ID because that file becomes the combined representation. * * @package s2Member\Utilities * @since 260903.1918 * * @param string $type `css` or `js`. * @param string $component `all`, `framework`, or `pro`. * @return array Logical generated filenames. */ public static function static_asset_ids($type = '', $component = 'all') { $type = strtolower((string)$type); $component = strtolower((string)$component); if(!in_array($type, array('css', 'js'), TRUE) || !in_array($component, array('all', 'framework', 'pro'), TRUE)) return array(); $framework = 's2member.'.$type; $pro = (!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_assets_combine'])) ? $framework : 's2member-pro.'.$type; if($component === 'framework') return array($framework); if($component === 'pro') return array($pro); $ids = array($framework); if((defined('WS_PLUGIN__S2MEMBER_PRO_VERSION') || isset($GLOBALS['WS_PLUGIN__']['s2member_pro'])) && $pro !== $framework) $ids[] = $pro; return $ids; } /** * Returns how s2Member text used by static JavaScript should be delivered. * * @package s2Member\Utilities * @since 260906.2049 * * @return string `static` to include text in generated JavaScript, or `page` to load it with each WordPress page. */ public static function static_js_text_delivery() { return (!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_js_text']) && $GLOBALS['WS_PLUGIN__']['s2member']['o']['static_js_text'] === 'page') ? 'page' : 'static'; } /** * Determines whether page-loaded JavaScript text is supported by the active Framework/Pro combination. * * Framework can always load its own text with the page. Pro explicitly advertises support because * older Pro releases predate the shipped static-JS data map needed for this delivery mode. * * @package s2Member\Utilities * @since 260906.2049 * * @return bool True if page-loaded JavaScript text is available. */ public static function static_js_page_text_supported() { if(!c_ws_plugin__s2member_utils_conds::pro_is_installed()) return TRUE; return (bool)apply_filters('ws_plugin__s2member_static_js_page_text_supported', FALSE); } /** * Determines whether the active Pro JavaScript hook contains only built-in callbacks safe to cache in a static file. * * This lets a newer Framework retain static-file delivery with older Pro releases that predate * the static-source filters. Unknown, reordered, or deprecated gateway callbacks remain dynamic. * * @package s2Member\Utilities * @since 260906.2049 * * @return bool True if the active Pro hook can be captured safely. */ protected static function static_js_builtin_pro_callbacks_supported() { if(!c_ws_plugin__s2member_utils_conds::pro_is_installed() || has_filter('ws_plugin__s2member_pro_available_gateways')) return FALSE; $built_ins = array( 'c_ws_plugin__s2member_pro_css_js::js_w_globals', 'c_ws_plugin__s2member_pro_paypal_css_js::paypal_js_w_globals', 'c_ws_plugin__s2member_pro_stripe_css_js::stripe_js_w_globals', 'c_ws_plugin__s2member_pro_authnet_css_js::authnet_js_w_globals', 'c_ws_plugin__s2member_pro_clickbank_css_js::clickbank_js_w_globals', ); $callbacks = isset($GLOBALS['wp_filter']['ws_plugin__s2member_during_js_w_globals']) ? $GLOBALS['wp_filter']['ws_plugin__s2member_during_js_w_globals'] : array(); if(is_object($callbacks) && isset($callbacks->callbacks)) $callbacks = $callbacks->callbacks; $found = FALSE; foreach((array)$callbacks as $priority => $priority_callbacks) foreach((array)$priority_callbacks as $callback) { 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) return FALSE; $found = TRUE; } return $found; } /** * Captures built-in Pro JavaScript from an older Pro release for static-file text delivery. * * @package s2Member\Utilities * @since 260906.2049 * * @return string Captured Pro JavaScript, or an empty string if the hook is not safely cacheable. */ protected static function static_js_builtin_pro_output() { if(!self::static_js_builtin_pro_callbacks_supported()) return ''; //260906.2256 Match the template variables passed by the normal dynamic loader when capturing older Pro callbacks. $u = $GLOBALS['WS_PLUGIN__']['s2member']['c']['dir_url']; $i = $u.'/src/images'; ob_start(); do_action('ws_plugin__s2member_during_js_w_globals', get_defined_vars()); return (string)ob_get_clean(); } /** * Returns shipped static JavaScript data maps used by one generated static JavaScript file. * * Framework owns its data map. Pro appends its independent data map through a filter so the two * release packages never need cross-repo slot coordination. * * @package s2Member\Utilities * @since 260906.0738 * * @param string $id Logical generated JavaScript filename. * @return array Data-map paths keyed by the compact browser-data namespace. */ protected static function static_js_data_map_paths($id = '') { if(self::static_js_text_delivery() !== 'page') return array(); $id = strtolower((string)$id); $paths = array(); if($id === 's2member.js') $paths['f'] = $GLOBALS['WS_PLUGIN__']['s2member']['c']['dir'].'/src/includes/s2member.js.php'; $paths = (array)apply_filters('ws_plugin__s2member_static_js_data_map_paths', $paths, $id, get_defined_vars()); foreach($paths as $key => $path) if(!preg_match('/^[a-z][a-z0-9_]*$/i', (string)$key) || !(string)$path) unset($paths[$key]); return $paths; } /** * Parses one shipped static JavaScript data map into an exact expression-to-slot lookup. * * @package s2Member\Utilities * @since 260906.0738 * * @param string $path Data-map path. * @return array Parse result. */ protected static function static_js_data_map($path = '') { $path = (string)$path; if(isset(self::$static_js_data_map_cache[$path])) return self::$static_js_data_map_cache[$path]; if(!$path || !is_readable($path) || ($source = file_get_contents($path)) === FALSE) return self::$static_js_data_map_cache[$path] = array('ok' => FALSE, 'slots' => array(), 'hash' => '', 'error' => 'Static JavaScript data map is not readable: '.$path); $slots = array(); if(!preg_match_all('/\\$data\\[(\\d+)\\]\\s*=\\s*\\/\\*d\\*\\/(.*?)\\/\\*b\\*\\/;/s', $source, $matches, PREG_SET_ORDER)) return self::$static_js_data_map_cache[$path] = array('ok' => FALSE, 'slots' => array(), 'hash' => '', 'error' => 'Static JavaScript data map contains no marked entries: '.$path); foreach($matches as $index => $match) { $slot = (int)$match[1]; $expression = trim((string)$match[2]); if($slot !== $index || !$expression || isset($slots[$expression])) 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); $slots[$expression] = $slot; } return self::$static_js_data_map_cache[$path] = array('ok' => TRUE, 'slots' => $slots, 'hash' => hash('sha256', $source), 'error' => ''); } /** * Returns the current shipped data-map signature for one static JavaScript representation. * * @package s2Member\Utilities * @since 260906.0738 * * @param string $id Logical generated JavaScript filename. * @return array Signature result. */ protected static function static_js_data_map_signature($id = '') { $hashes = array(); foreach(self::static_js_data_map_paths($id) as $key => $path) { $data_map = self::static_js_data_map($path); if(empty($data_map['ok'])) return array('ok' => FALSE, 'signature' => '', 'error' => (string)$data_map['error']); $hashes[(string)$key] = (string)$data_map['hash']; } if(!$hashes) return array('ok' => FALSE, 'signature' => '', 'error' => 'No static JavaScript data map is available for '.$id); return array('ok' => TRUE, 'signature' => hash('sha256', wp_json_encode($hashes)), 'error' => ''); } /** * Returns the data-map signature saved with the active generated JavaScript file. * * @package s2Member\Utilities * @since 260906.0738 * * @param string $id Logical generated JavaScript filename. * @return string Saved signature. */ protected static function static_asset_data_map_signature($id = '') { $signatures = get_option('ws_plugin__s2member_static_asset_data_map_signatures', array()); return (is_array($signatures) && isset($signatures[$id])) ? (string)$signatures[$id] : ''; } /** * Saves the data-map signature paired with one generated JavaScript file. * * @package s2Member\Utilities * @since 260906.0738 * * @param string $id Logical generated JavaScript filename. * @param string $signature Current data-map signature. * @return null */ protected static function set_static_asset_data_map_signature($id = '', $signature = '') { if(!in_array($id, array('s2member.js', 's2member-pro.js'), TRUE)) return; $signatures = get_option('ws_plugin__s2member_static_asset_data_map_signatures', array()); $signatures = is_array($signatures) ? $signatures : array(); $signatures[$id] = (string)$signature; update_option('ws_plugin__s2member_static_asset_data_map_signatures', $signatures); return; } /** * Returns the signed build timestamp for one generated frontend asset file. * * Positive values are current. Negative values preserve the previous timestamp while marking that exact file stale. * * @package s2Member\Utilities * @since 260903.0525 * * @param string $id Logical generated filename, e.g. `s2member.js` or `s2member-pro.css`. * @return int Signed build timestamp. */ public static function static_asset_build($id = '') { $id = strtolower((string)$id); if(in_array($id, array('css', 'js'), TRUE)) $id = 's2member.'.$id; $builds = get_option('ws_plugin__s2member_static_asset_builds', array()); 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; } /** * Updates the signed build timestamp for one generated frontend asset file. * * @package s2Member\Utilities * @since 260903.0525 * * @param string $id Logical generated filename. * @param int $build Signed build timestamp. * @return null */ protected static function set_static_asset_build($id = '', $build = 0) { $id = strtolower((string)$id); if(!in_array($id, array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js'), TRUE)) return; $builds = get_option('ws_plugin__s2member_static_asset_builds', array()); $builds = is_array($builds) ? $builds : array(); //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. $builds = array_intersect_key($builds, array_flip(array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js'))); $builds[$id] = (int)$build; update_option('ws_plugin__s2member_static_asset_builds', $builds); unset(self::$static_asset_cache[$id]); self::$static_assets_health_cache = NULL; delete_option('ws_plugin__s2member_static_asset_health'); return; } /** * Clears generated frontend asset build state when the active file representation changes. * * Existing timestamped files remain on disk for already-cached HTML; fresh requests generate only the newly active representation. * * @package s2Member\Utilities * @since 260903.1918 * * @return null */ protected static function reset_static_asset_builds() { delete_option('ws_plugin__s2member_static_asset_builds'); delete_option('ws_plugin__s2member_static_asset_data_map_signatures'); //260906.1530 Static JavaScript and its data-map slot layout must stay synchronized. delete_option('ws_plugin__s2member_static_asset_health'); self::$static_asset_cache = array(); self::$static_js_data_map_cache = array(); self::$static_assets_health_cache = NULL; foreach(array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js') as $id) delete_transient('ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id)); return; } /** * Invalidates selected generated frontend assets. * * Selectors may be `css`, `js`, `framework_css`, `framework_js`, `pro_css`, `pro_js`, or exact logical generated filenames. * Existing files remain available for already-cached HTML. Current pages stop referencing a stale generation until its replacement succeeds. * * @package s2Member\Utilities * @since 260903.0437 * * @param array|string $assets Asset selectors. * @return null */ public static function invalidate_static_assets($assets = array('css', 'js')) { $assets = is_array($assets) ? $assets : array($assets); $ids = array(); foreach($assets as $asset) { $asset = strtolower((string)$asset); if(in_array($asset, array('css', 'js'), TRUE)) $ids = array_merge($ids, self::static_asset_ids($asset, 'all')); else if(preg_match('/^(framework|pro)_(css|js)$/', $asset, $match)) $ids = array_merge($ids, self::static_asset_ids($match[2], $match[1])); else if(in_array($asset, array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js'), TRUE)) $ids[] = $asset; } foreach(array_unique($ids) as $id) { $build = self::static_asset_build($id); //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. if($build) self::set_static_asset_build($id, -abs($build)); else unset(self::$static_asset_cache[$id]); delete_transient('ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id)); } return; } /** * Invalidates generated assets when s2Member or WordPress values rendered into them change. * * @package s2Member\Utilities * @since 260903.0437 * * @param string $option Option name. * @param mixed $old_value Previous value. * @param mixed $value New value. * @return null */ public static function maybe_invalidate_after_wp_option_update($option = '', $old_value = NULL, $value = NULL) { if($old_value === $value) return; if((string)$option === 'ws_plugin__s2member_options') { if(!is_array($old_value) || !is_array($value)) return; $old = (array)$old_value; $new = (array)$value; //260907.2203 Keep a concise operational history of CSS/JS configuration changes when s2Member logging is enabled. $config_changes = array(); foreach(array('dynamic_asset_loader', 'static_css', 'static_css_minify', 'static_js', 'static_js_text', 'static_js_minify', 'static_assets_combine') as $key) if(serialize(isset($old[$key]) ? $old[$key] : NULL) !== serialize(isset($new[$key]) ? $new[$key] : NULL)) $config_changes[$key] = array('old' => isset($old[$key]) ? $old[$key] : NULL, 'new' => isset($new[$key]) ? $new[$key] : NULL); if($config_changes) c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'CSS/JS configuration changed', 'changes' => $config_changes)); if((string)(isset($old['static_assets_combine']) ? $old['static_assets_combine'] : '0') !== (string)(isset($new['static_assets_combine']) ? $new['static_assets_combine'] : '0')) { //260903.1918 A combine-mode change changes what the s2member.* filenames represent; discard all build state so the new 2-file/4-file representation starts with fresh timestamps. self::reset_static_asset_builds(); return; } $keys = array( 'css' => array('static_css', 'static_css_minify'), 'js' => array('static_js', 'static_js_text', 'static_js_minify'), 'framework_js' => array('custom_reg_force_personal_emails', 'custom_reg_password_min_length', 'custom_reg_password_min_strength'), 'pro_css' => array('pro_gateways_enabled'), 'pro_js' => array( 'pro_gateways_enabled', 'pro_stripe_api_publishable_key', 'pro_stripe_api_image', 'pro_stripe_api_allow_remember_me', 'paypal_checkout_enable', 'paypal_checkout_sandbox', 'paypal_checkout_client_id', 'paypal_checkout_sandbox_client_id', 'sec_encryption_key', ), ); $keys = (array)apply_filters('ws_plugin__s2member_static_asset_option_keys', $keys, get_defined_vars()); $invalidate = array(); foreach($keys as $selector => $option_keys) foreach((array)$option_keys as $key) if((isset($old[$key]) || isset($new[$key])) && serialize(isset($old[$key]) ? $old[$key] : NULL) !== serialize(isset($new[$key]) ? $new[$key] : NULL)) { $invalidate[] = $selector; break; } if($invalidate) self::invalidate_static_assets($invalidate); return; } if(in_array((string)$option, array('siteurl', 'home'), TRUE)) self::invalidate_static_assets(array('css', 'js')); else if((string)$option === 'WPLANG' && self::static_js_text_delivery() !== 'page') self::invalidate_static_assets('js'); return; } /** * Invalidates generated frontend assets when plugin activation/deactivation can change frontend integrations. * * @package s2Member\Utilities * @since 260903.0437 * * @return null */ public static function invalidate_after_plugin_change($plugin = '') { $plugin = (string)$plugin; if($plugin === 's2member-pro/s2member-pro.php') self::reset_static_asset_builds(); else if($plugin === 'buddypress/bp-loader.php') self::invalidate_static_assets('framework_js'); return; } /** * Invalidates generated frontend assets after plugin/translation upgrades. * * @package s2Member\Utilities * @since 260903.0437 * * @param object $upgrader WordPress upgrader instance. * @param array $options Upgrade details. * @return null */ public static function maybe_invalidate_after_upgrade($upgrader = NULL, $options = array()) { if(!is_array($options) || empty($options['type'])) return; if($options['type'] === 'translation') { if(self::static_js_text_delivery() !== 'page') self::invalidate_static_assets('js'); //260906.2049 Page-loaded JavaScript text follows the current translation without rebuilding the external static file. return; } if($options['type'] !== 'plugin') return; $plugins = array(); if(!empty($options['plugin'])) $plugins[] = (string)$options['plugin']; if(!empty($options['plugins']) && is_array($options['plugins'])) $plugins = array_merge($plugins, $options['plugins']); foreach(array_unique($plugins) as $plugin) { if($plugin === 's2member/s2member.php') self::invalidate_static_assets(array('framework_css', 'framework_js')); else if($plugin === 's2member-pro/s2member-pro.php') self::invalidate_static_assets(array('pro_css', 'pro_js')); else if($plugin === 'buddypress/bp-loader.php') self::invalidate_static_assets('framework_js'); } return; } /** * Returns one current generated frontend asset URL, building it when stale/uninitialized. * * Active timestamped files are existence-checked before their URLs are emitted. A missing or * trusted-browser-confirmed unreachable file returns a failure so callers can use dynamic delivery immediately. * * @package s2Member\Utilities * @since 260903.0525 * * @param string $id Logical generated filename. * @param bool $force Force a new build timestamp immediately. * @return array Result with `ok`, `url`, `build`, and `error` keys. */ public static function ensure_static_asset($id = '', $force = FALSE) { $id = strtolower((string)$id); if(in_array($id, array('css', 'js'), TRUE)) $id = 's2member.'.$id; if(!in_array($id, array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js'), TRUE)) return array('ok' => FALSE, 'url' => '', 'build' => 0, 'error' => 'Invalid static asset ID'); $type = substr(strrchr($id, '.'), 1); if(!in_array($id, self::static_asset_ids($type, 'all'), TRUE)) return array('ok' => FALSE, 'url' => '', 'build' => 0, 'error' => 'Static asset is not active in the current delivery mode'); if(!$force && isset(self::$static_asset_cache[$id])) return self::$static_asset_cache[$id]; //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. $compatibility = self::static_asset_definition($id, FALSE); if(empty($compatibility['ok'])) return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => abs(self::static_asset_build($id)), 'error' => (string)$compatibility['error']); $state = self::static_asset_build($id); $dirty = $state < 0; $active_build = abs($state); $base = substr($id, 0, -strlen('.'.$type)); $data_map_signature = array('ok' => TRUE, 'signature' => '', 'error' => ''); $uses_data_map = $type === 'js' && self::static_js_text_delivery() === 'page'; if($uses_data_map) { $data_map_signature = self::static_js_data_map_signature($id); if(empty($data_map_signature['ok'])) return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => (string)$data_map_signature['error']); //260906.1530 Regenerate static JavaScript when its shipped data-map layout changes so slot numbers stay synchronized. if($active_build && self::static_asset_data_map_signature($id) !== (string)$data_map_signature['signature']) { $dirty = TRUE; $state = -$active_build; self::set_static_asset_build($id, $state); } } if(!$force && !$dirty && $active_build) { $location = self::static_assets_location(FALSE); if(empty($location['ok'])) return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => $location['error']); $url = $location['url'].'/'.$base.'-'.$active_build.'.'.$type; $path = $location['dir'].'/'.$base.'-'.$active_build.'.'.$type; //260904.2110 A few local file checks are cheaper than sending a broken static URL. Missing or browser-confirmed unreachable files fall back to dynamic delivery immediately. if(!is_file($path)) return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => 'Expected static asset '.$id.' is missing.'); if(self::asset_http_target_failed('static:'.$id, $url)) 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.'); return self::$static_asset_cache[$id] = array('ok' => TRUE, 'url' => $url, 'build' => $active_build, 'error' => ''); } $failure_key = 'ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id); if(!$force && $dirty && ($failure = get_transient($failure_key))) return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => (string)$failure); $definition = self::static_asset_definition($id, TRUE); if(empty($definition['ok'])) return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => (string)$definition['error']); $build = max(time(), $active_build + 1); $result = self::build_static_asset($base, $build, $type, $definition['sources'], !empty($definition['minify'])); if(!empty($result['ok'])) { if($uses_data_map) self::set_static_asset_data_map_signature($id, (string)$data_map_signature['signature']); self::set_static_asset_build($id, $build); //260907.2203 Record successful generation so automatic and manual rebuilds remain visible later. c_ws_plugin__s2member_utils_logs::log_entry('css-js', array( 'event' => 'Static CSS/JS asset generated', 'result' => 'success', 'asset' => $id, 'build' => $build, 'trigger' => $force ? 'forced refresh' : 'automatic generation', 'minified' => !empty($definition['minify']), 'url' => $result['url'], )); //260905.0106 Prune only after the new timestamp is current so the previous generation is treated as stale instead of protected. self::prune_static_asset_generations(dirname($result['path']), $result['path']); delete_transient($failure_key); return self::$static_asset_cache[$id] = array('ok' => TRUE, 'url' => $result['url'], 'build' => $build, 'error' => ''); } set_transient($failure_key, (string)$result['error'], 5 * MINUTE_IN_SECONDS); //260907.2203 Preserve failed generation details even when delivery later falls back or recovers automatically. c_ws_plugin__s2member_utils_logs::log_entry('css-js', array( 'event' => 'Static CSS/JS asset generation failed', 'result' => 'failure', 'asset' => $id, 'attempted_build' => $build, 'previous_build' => $active_build, 'trigger' => $force ? 'forced refresh' : 'automatic generation', 'error' => (string)$result['error'], )); return self::$static_asset_cache[$id] = array('ok' => FALSE, 'url' => '', 'build' => $active_build, 'error' => $result['error']); } /** * Returns all active generated frontend assets for one type. * * 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. * * @package s2Member\Utilities * @since 260903.1918 * * @param string $type `css` or `js`. * @param bool $force Force fresh timestamps for every active file of this type. * @return array Aggregate result with individual assets keyed by logical filename. */ public static function ensure_static_assets($type = '', $force = FALSE) { $type = strtolower((string)$type); if(!in_array($type, array('css', 'js'), TRUE)) return array('ok' => FALSE, 'assets' => array(), 'error' => 'Invalid static asset type'); $option = 'static_'.$type; if(empty($GLOBALS['WS_PLUGIN__']['s2member']['o'][$option])) return array('ok' => FALSE, 'assets' => array(), 'error' => 'Static '.strtoupper($type).' Delivery is disabled'); $assets = array(); foreach(self::static_asset_ids($type, 'all') as $id) { $assets[$id] = self::ensure_static_asset($id, $force); if(empty($assets[$id]['ok'])) return array('ok' => FALSE, 'assets' => $assets, 'error' => (string)$assets[$id]['error']); } return array('ok' => (bool)$assets, 'assets' => $assets, 'error' => ''); } /** * Refreshes all currently enabled static frontend asset types immediately. * * CSS and JS remain independent; within each type only files active in the current separate/combined representation are rebuilt. * * @package s2Member\Utilities * @since 260903.0525 * * @return array Results keyed by `css` and/or `js`. */ public static function refresh_static_assets() { $results = array(); foreach(array('css' => 'static_css', 'js' => 'static_js') as $type => $option) if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o'][$option])) { foreach(self::static_asset_ids($type, 'all') as $id) { unset(self::$static_asset_cache[$id]); delete_transient('ws_plugin__s2member_static_asset_failure_'.str_replace('.', '_', $id)); } $results[$type] = self::ensure_static_assets($type, TRUE); } return $results; } /** * AJAX handler for the General Options “Refresh Static Assets” button. * * @package s2Member\Utilities * @since 260903.0437 * * @return null Exits through WordPress JSON helpers. */ public static function ajax_refresh_static_assets() { check_ajax_referer('ws-plugin--s2member-refresh-static-assets'); if(!current_user_can('create_users')) wp_send_json_error(array('message' => 'You do not have permission to refresh s2Member static assets.'), 403); $results = self::refresh_static_assets(); if(!$results) wp_send_json_error(array('message' => 'Enable Static CSS Delivery or Static JS Delivery and save the options first.'), 400); $success = $errors = array(); foreach($results as $type => $result) if(!empty($result['ok'])) $success[] = strtoupper($type); else $errors[] = strtoupper($type).': '.((!empty($result['error'])) ? $result['error'] : 'unknown build error'); if(!$errors) { //260907.2203 Record the administrator-triggered refresh result. c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'Manual Static CSS/JS refresh', 'result' => 'success', 'refreshed' => $success)); wp_send_json_success(array('message' => 'Static '.implode(' + ', $success).' refreshed. New timestamped files are active.')); } //260907.2203 Record partial and failed administrator-triggered refreshes too. 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)); $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.'; wp_send_json_error(array('message' => $message), 500); } /** * Checks the few currently active generated files for local filesystem availability. * * 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. * * @package s2Member\Utilities * @since 260903.0525 * * @param bool $force Recheck even if this request already has a cached result. * @return array Missing active asset files keyed by logical filename. */ public static function static_assets_health($force = FALSE) { if(!$force && isset(self::$static_assets_health_cache)) return self::$static_assets_health_cache; if(empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_css']) && empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_js'])) return self::$static_assets_health_cache = array(); $missing = array(); $location = self::static_assets_location(FALSE); if(empty($location['ok'])) $missing['location'] = $location['error']; else foreach(array('css' => 'static_css', 'js' => 'static_js') as $type => $option) if(!empty($GLOBALS['WS_PLUGIN__']['s2member']['o'][$option])) foreach(self::static_asset_ids($type, 'all') as $id) { $build = self::static_asset_build($id); $base = substr($id, 0, -strlen('.'.$type)); if($build > 0 && !is_file($location['dir'].'/'.$base.'-'.$build.'.'.$type)) $missing[$id] = 'Expected static asset '.$id.' is missing.'; } //260907.2203 Log only local-health transitions so recurring admin checks do not repeat the same event. $previous = get_option('ws_plugin__s2member_static_asset_health', array()); $previous = is_array($previous) ? $previous : array(); if(serialize($previous) !== serialize($missing)) { update_option('ws_plugin__s2member_static_asset_health', $missing, FALSE); if($missing) c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'Static CSS/JS local health issue', 'result' => 'failure', 'issues' => $missing)); else if($previous) c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'Static CSS/JS local health recovered', 'result' => 'recovered', 'previous_issues' => $previous)); } return self::$static_assets_health_cache = $missing; } /** * Displays a branded admin warning for missing or browser-confirmed unreachable frontend assets. * * @package s2Member\Utilities * @since 260903.0612 * * @attaches-to ``add_action('admin_notices');`` * @return null */ public static function static_assets_admin_notice() { if(!current_user_can('create_users') || (defined('DOING_AJAX') && DOING_AJAX)) return; $messages = array(); $static_settings_url = add_query_arg('s2member-open-panel', 'frontend-static-assets', admin_url('/admin.php?page=ws-plugin--s2member-gen-ops')).'#ws-plugin--s2member-static-assets'; $dynamic_settings_url = add_query_arg('s2member-open-panel', 'dynamic-asset-loader', admin_url('/admin.php?page=ws-plugin--s2member-gen-ops')).'#ws-plugin--s2member-dynamic-asset-loader-section'; $health = self::static_assets_health(); if($health) $messages[] = esc_html(implode(' ', $health)).' Pages that need the missing file are using dynamic delivery instead. Open Static CSS/JS Optimization and refresh the static assets.'; $using_s2o = empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader']) || $GLOBALS['WS_PLUGIN__']['s2member']['o']['dynamic_asset_loader'] !== 'wordpress'; $s2o_missing = $using_s2o && !is_file(self::s2o_file_path()); //260907.2203 Track missing/recovered loader transitions without logging every admin health check. $s2o_missing_logged = (bool)get_option('ws_plugin__s2member_css_js_s2o_missing', FALSE); if($s2o_missing && !$s2o_missing_logged) { update_option('ws_plugin__s2member_css_js_s2o_missing', 1, FALSE); c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 's2Member Dynamic Loader file missing', 'result' => 'failure', 'file' => self::s2o_file_path(), 'fallback' => 'WordPress Dynamic Loader')); } else if($using_s2o && !$s2o_missing && $s2o_missing_logged) { delete_option('ws_plugin__s2member_css_js_s2o_missing'); c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 's2Member Dynamic Loader file recovered', 'result' => 'recovered', 'file' => self::s2o_file_path())); } if($s2o_missing) $messages[] = 'The selected s2Member Dynamic Loader file s2member-o.php is missing. s2Member is using the WordPress Dynamic Loader instead. Restore the file or choose the WordPress Dynamic Loader.'; $http_health = self::asset_http_health_state(); $has_confirmed_failure = (bool)($health || $s2o_missing); if(is_array($http_health) && !empty($http_health['runtime_warnings']) && is_array($http_health['runtime_warnings'])) foreach($http_health['runtime_warnings'] as $warning) if(!empty($warning['reported']) && (int)$warning['reported'] >= time() - HOUR_IN_SECONDS) $messages[] = 'A real frontend page reported that '.esc_html((string)$warning['id']).' did not become active, even though a follow-up browser check could load the expected file and marker. This can indicate script/style optimization, execution order, a browser extension, or another runtime conflict. Delivery has not been changed automatically.'; if(is_array($http_health) && !empty($http_health['failures']) && is_array($http_health['failures'])) foreach($http_health['failures'] as $id => $failure) { $status = (!empty($failure['status'])) ? ' HTTP '.(int)$failure['status'].'.' : ''; if($id === 's2o' && $using_s2o && !$s2o_missing && !empty($failure['url']) && (string)$failure['url'] === (string)$GLOBALS['WS_PLUGIN__']['s2member']['c']['s2o_url']) { $has_confirmed_failure = TRUE; $messages[] = 'The selected s2Member Dynamic Loader could not be reached.'.$status.' s2Member is using the WordPress Dynamic Loader instead. Review Dynamic CSS/JS Loader or see Mod Security (Odd 403, 503, 500 Errors).'; } else if(strpos((string)$id, 'static:') === 0 && !empty($failure['url']) && self::asset_http_target_failed($id, (string)$failure['url'])) { $has_confirmed_failure = TRUE; $messages[] = 'A generated static file could not be loaded from its public URL.'.$status.' Pages that need it are using dynamic delivery instead. Open Static CSS/JS Optimization.'; } else if(strpos((string)$id, 'runtime:') === 0 && !empty($http_health['checked']) && (int)$http_health['checked'] >= time() - HOUR_IN_SECONDS) { $has_confirmed_failure = TRUE; $messages[] = 'A dynamically generated frontend asset could not be loaded or did not contain its expected completion marker.'.$status.' Review the browser console and your CSS/JavaScript optimization or security settings.'; } } if($messages) c_ws_plugin__s2member_admin_notices::display_branded_notice('s2Member Frontend Asset Notice', implode('

', $messages), $has_confirmed_failure); return; } /** * Prints an infrequent trusted browser-side reachability probe for active frontend assets. * * Healthy static files use HEAD. The lightweight loader uses its tiny pre-WordPress health mode. * A frontend runtime suspicion forces one full cache-busted marker check for that exact URL. * * @package s2Member\Utilities * @since 260904.2110 * * @attaches-to ``add_action('admin_footer');`` * @attaches-to ``add_action('wp_footer');`` * @return null */ public static function asset_http_health_probe() { if(!current_user_can('create_users') || (defined('DOING_AJAX') && DOING_AJAX)) return; $targets = self::asset_http_health_targets(); if(!$targets) return; $target_hash = self::asset_http_health_target_hash($targets); $health = self::asset_http_health_state(); $has_failures = is_array($health) && !empty($health['failures']); $has_suspicions = (bool)self::asset_runtime_suspicions(); $interval = ($has_failures || $has_suspicions) ? MINUTE_IN_SECONDS : 10 * MINUTE_IN_SECONDS; if(!$has_suspicions && is_array($health) && !empty($health['checked']) && !empty($health['target_hash']) && (string)$health['target_hash'] === $target_hash && (int)$health['checked'] >= time() - $interval) return; $config = array( 'targets' => array_values($targets), 'target_hash' => $target_hash, 'ajax_url' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('ws-plugin--s2member-asset-http-health'), 'reload_on_change' => is_admin(), ); echo '' . "\n"; return; } /** * Stores trusted administrator-browser asset probe results. * * @package s2Member\Utilities * @since 260904.2110 * * @return null Exits through WordPress JSON helpers. */ public static function ajax_asset_http_health_report() { check_ajax_referer('ws-plugin--s2member-asset-http-health'); if(!current_user_can('create_users')) wp_send_json_error(array('message' => 'You do not have permission to report s2Member asset health.'), 403); $targets = self::asset_http_health_targets(); $target_hash = self::asset_http_health_target_hash($targets); if(empty($_POST['target_hash']) || (string)wp_unslash($_POST['target_hash']) !== $target_hash) wp_send_json_success(array('stale' => TRUE, 'reload' => FALSE)); $results = (!empty($_POST['results'])) ? json_decode(wp_unslash($_POST['results']), TRUE) : array(); $by_id = array(); if(is_array($results)) foreach($results as $result) if(is_array($result) && !empty($result['id'])) $by_id[(string)$result['id']] = $result; $old = self::asset_http_health_state(); $old_failures = (is_array($old) && !empty($old['failures']) && is_array($old['failures'])) ? $old['failures'] : array(); $runtime_warnings = (is_array($old) && !empty($old['runtime_warnings']) && is_array($old['runtime_warnings'])) ? $old['runtime_warnings'] : array(); foreach($runtime_warnings as $key => $warning) if(empty($warning['reported']) || (int)$warning['reported'] < time() - HOUR_IN_SECONDS) unset($runtime_warnings[$key]); $old_runtime_warnings = $runtime_warnings; //260907.2203 Preserve prior warning state so only new trusted transitions are logged. $failures = array(); $suspicions = self::asset_runtime_suspicions(); foreach($targets as $id => $target) { $result = (isset($by_id[$id]) && is_array($by_id[$id])) ? $by_id[$id] : array(); if(empty($result['ok'])) { $failure_id = (!empty($target['failure_id'])) ? (string)$target['failure_id'] : $id; $failures[$failure_id] = array( 'url' => (!empty($target['failure_url'])) ? (string)$target['failure_url'] : (string)$target['url'], 'label' => (string)$target['label'], 'status' => (!empty($result['status'])) ? (int)$result['status'] : 0, 'content_type' => (!empty($result['content_type'])) ? substr(sanitize_text_field((string)$result['content_type']), 0, 100) : '', 'detail' => (!empty($result['detail'])) ? substr(sanitize_text_field((string)$result['detail']), 0, 160) : '', ); } else if(!empty($target['suspicion_key']) && !empty($target['suspicion'])) { $key = (string)$target['suspicion_key']; $runtime_warnings[$key] = array( 'id' => (string)$target['suspicion']['id'], 'url' => (string)$target['suspicion']['url'], 'delivery' => (string)$target['suspicion']['delivery'], 'reported' => time(), ); } if(!empty($target['suspicion_key'])) unset($suspicions[(string)$target['suspicion_key']]); } update_option('ws_plugin__s2member_asset_runtime_suspicions', $suspicions, FALSE); //260907.2203 Keep an operational history of newly confirmed failures, recoveries, and runtime warnings. foreach($failures as $id => $failure) if(!isset($old_failures[$id]) || serialize($old_failures[$id]) !== serialize($failure)) c_ws_plugin__s2member_utils_logs::log_entry('css-js', array( 'event' => 'CSS/JS delivery health failure', 'result' => 'failure', 'target' => $id, 'details' => $failure, 'fallback' => ($id === 's2o') ? 'WordPress Dynamic Loader' : ((strpos((string)$id, 'static:') === 0) ? 'dynamic delivery' : 'none'), )); foreach($old_failures as $id => $failure) if(!isset($failures[$id])) c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'CSS/JS delivery health recovered', 'result' => 'recovered', 'target' => $id, 'previous_details' => $failure)); foreach($runtime_warnings as $key => $warning) if(!isset($old_runtime_warnings[$key])) c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'CSS/JS runtime warning confirmed', 'result' => 'warning', 'details' => $warning, 'delivery_changed' => FALSE)); $new_health = array('checked' => time(), 'target_hash' => $target_hash, 'failures' => $failures, 'runtime_warnings' => $runtime_warnings); update_option('ws_plugin__s2member_asset_http_health', $new_health, FALSE); self::$asset_http_health_cache = $new_health; wp_send_json_success(array('failures' => count($failures), 'runtime_warnings' => count($runtime_warnings), 'reload' => serialize($old_failures) !== serialize($failures))); } /** * Prints the late real-page asset marker monitor. * * A normal browser has already finished loading ordinary CSS/JavaScript by window.load, so a short extra grace period is enough to avoid racing normal delivery. Healthy pages make no request. A recoverable miss uses one WordPress fallback request that also carries compact signed diagnostic details. * * @package s2Member\Utilities * @since 260904.2255 * * @attaches-to ``add_action('wp_footer');`` * @return null */ public static function page_asset_runtime_monitor() { if(is_admin() || !self::$page_asset_expectations) return; $expectations = array(); $recovery = array('css' => '', 'js' => ''); foreach(self::$page_asset_expectations as $expectation) { $expectations[] = array( (string)$expectation['id'], (string)$expectation['asset_id'], (string)$expectation['url'], (string)$expectation['delivery'], (string)$expectation['token'], (string)$expectation['signature'], ); if(!empty($expectation['recovery_url']) && empty($recovery[(string)$expectation['type']])) $recovery[(string)$expectation['type']] = (string)$expectation['recovery_url']; } $config = array('a' => admin_url('admin-ajax.php'), 'e' => $expectations, 'r' => $recovery, 'd' => 1000); echo '' . "\n"; return; } /** * Records signed low-trust frontend runtime suspicions without changing delivery state. * * Reports are rate-limited and only force a later trusted administrator-browser confirmation. A recovery request and the standalone AJAX reporter share this validator so successful page-local fallback normally needs no separate reporting request. * * @package s2Member\Utilities * @since 260905.0009 * * @param array $missing Missing runtime expectations. * @return int Number of newly recorded suspicions. */ protected static function record_asset_runtime_suspicions($missing = array()) { if(!is_array($missing) || !$missing) return 0; $missing = array_slice($missing, 0, 4); $suspicions = self::asset_runtime_suspicions(); $recorded = 0; foreach($missing as $expectation) { if(is_array($expectation) && isset($expectation[0]) && !isset($expectation['id'])) $expectation = self::expand_asset_runtime_expectation($expectation); if(!is_array($expectation) || empty($expectation['signature'])) continue; $signature = (string)$expectation['signature']; unset($expectation['signature']); if(!hash_equals(self::asset_runtime_expectation_signature($expectation), $signature) || !self::asset_runtime_expectation_is_current($expectation)) continue; $key = md5((string)$expectation['id']."\0".(string)$expectation['url']."\0".(string)$expectation['marker']); if(get_transient('ws_plugin__s2member_asset_runtime_suspect_'.$key)) continue; $first_report = empty($suspicions[$key]); //260907.2203 Avoid duplicating the same low-trust runtime suspicion in the log. set_transient('ws_plugin__s2member_asset_runtime_suspect_'.$key, 1, MINUTE_IN_SECONDS); $expectation['reported'] = time(); $expectation['signature'] = $signature; $suspicions[$key] = $expectation; //260907.2203 Record the first frontend runtime suspicion for later troubleshooting and trusted confirmation. if($first_report) c_ws_plugin__s2member_utils_logs::log_entry('css-js', array( 'event' => 'Frontend CSS/JS runtime issue reported', 'result' => 'suspected', 'asset' => (string)$expectation['id'], 'delivery' => (string)$expectation['delivery'], 'url' => (string)$expectation['url'], 'trusted_confirmation_pending' => TRUE, )); $recorded++; } if($recorded) update_option('ws_plugin__s2member_asset_runtime_suspicions', $suspicions, FALSE); return $recorded; } /** * Records signed runtime suspicions carried by a page-local WordPress recovery request. * * @package s2Member\Utilities * @since 260905.0009 * * @return int Number of newly recorded suspicions. */ public static function record_asset_runtime_recovery_suspicion() { if(empty($_GET['s2member_asset_runtime_suspect'])) return 0; $missing = json_decode(wp_unslash($_GET['s2member_asset_runtime_suspect']), TRUE); return self::record_asset_runtime_suspicions($missing); } /** * Records a low-trust frontend runtime suspicion without changing delivery state. * * This endpoint remains available for misses where loading a full fallback could duplicate JavaScript that already ran. * * @package s2Member\Utilities * @since 260904.2255 * * @return null Exits through WordPress JSON helpers. */ public static function ajax_asset_runtime_suspicion() { $missing = (!empty($_POST['missing'])) ? json_decode(wp_unslash($_POST['missing']), TRUE) : array(); wp_send_json_success(array('recorded' => self::record_asset_runtime_suspicions($missing))); } /** * Returns the source definition for one currently enabled/compatible generated frontend asset file. * * @package s2Member\Utilities * @since 260903.0525 * * @param string $id Logical generated filename. * @param bool $include_sources Build ordered source definitions only when an asset actually needs generation. * @return array Definition result. */ protected static function static_asset_definition($id = '', $include_sources = TRUE) { $id = strtolower((string)$id); if(!in_array($id, array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js'), TRUE)) return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Invalid static asset ID'); $type = substr(strrchr($id, '.'), 1); $pro_file = strpos($id, 's2member-pro.') === 0; $combine = !$pro_file && !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['static_assets_combine']); $o = $GLOBALS['WS_PLUGIN__']['s2member']['o']; $c = $GLOBALS['WS_PLUGIN__']['s2member']['c']; if($pro_file && !in_array($id, self::static_asset_ids($type, 'all'), TRUE)) return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Separate Pro static asset is not active'); if($type === 'css') { if(empty($o['static_css'])) return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Static CSS Delivery is disabled'); //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. $framework_dynamic = has_action('ws_plugin__s2member_before_css') || isset($GLOBALS['wp_filter']['all']); $hook_dynamic = has_action('ws_plugin__s2member_during_css'); $hook_dynamic = (bool)apply_filters('ws_plugin__s2member_dynamic_css_required', $hook_dynamic, get_defined_vars()); if($framework_dynamic || $hook_dynamic) return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Current CSS hooks require legacy dynamic assets'); if(!$include_sources) return array('ok' => TRUE, 'sources' => array(), 'minify' => !empty($o['static_css_minify']), 'error' => ''); $sources = ($pro_file) ? array() : array(array('file' => $c['dir'].'/src/includes/s2member.css', 'preserve_header' => TRUE)); if(!$pro_file) $sources = (array)apply_filters('ws_plugin__s2member_static_css_sources', $sources, get_defined_vars()); if($pro_file || $combine) $sources = (array)apply_filters('ws_plugin__s2member_static_pro_css_sources', $sources, get_defined_vars()); if(!$sources) return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'No static CSS sources are available for '.$id); return array('ok' => TRUE, 'sources' => $sources, 'minify' => !empty($o['static_css_minify']), 'error' => ''); } if($type === 'js') { if(empty($o['static_js'])) return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Static JS Delivery is disabled'); if(!function_exists('wp_add_inline_script')) return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Static JS requires WordPress 4.5+'); $page_text = self::static_js_text_delivery() === 'page'; if($page_text && !self::static_js_page_text_supported()) return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Loading JavaScript text with each WordPress page requires a current s2Member Pro version'); if($page_text) { //260906.2049 Text and other page-specific values resolve in the normal HTML request, so they do not make the external JavaScript dynamic. $framework_dynamic = apply_filters('ws_plugin__s2member_js_api_constants_enable', FALSE) || has_action('ws_plugin__s2member_before_js_w_globals') || isset($GLOBALS['wp_filter']['all']); } else { $site_locale = (string)get_option('WPLANG'); if(!$site_locale && defined('WPLANG')) $site_locale = (string)WPLANG; $site_locale = ($site_locale) ? $site_locale : 'en_US'; $current_locale = (function_exists('determine_locale')) ? (string)determine_locale() : (string)get_locale(); $framework_dynamic = apply_filters('ws_plugin__s2member_js_api_constants_enable', FALSE) || has_action('ws_plugin__s2member_before_js_w_globals') || $current_locale !== $site_locale || has_filter('ws_plugin__s2member_files_dir') || 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') || isset($GLOBALS['wp_filter']['all']); } $hook_dynamic = has_action('ws_plugin__s2member_during_js_w_globals'); $hook_dynamic = (bool)apply_filters('ws_plugin__s2member_dynamic_js_required', $hook_dynamic, get_defined_vars()); if($hook_dynamic && !$page_text && self::static_js_builtin_pro_callbacks_supported()) $hook_dynamic = FALSE; //260906.2049 Older Pro releases can still use static-file text when their JavaScript hook contains only known built-ins. if($framework_dynamic || $hook_dynamic) return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Current JavaScript hooks/configuration require dynamic assets'); if($page_text) { $data_map_signature = self::static_js_data_map_signature($id); if(empty($data_map_signature['ok'])) return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => (string)$data_map_signature['error']); } if(!$include_sources) return array('ok' => TRUE, 'sources' => array(), 'minify' => !empty($o['static_js_minify']), 'error' => ''); $sources = ($pro_file) ? array() : array( array('file' => $c['dir'].'/src/includes/jquery/jquery.sprintf/jquery.sprintf.js', 'preserve_header' => TRUE), ($page_text) ? array('file' => $c['dir'].'/src/includes/s2member.js', 'data_map' => $c['dir'].'/src/includes/s2member.js.php', 'data_key' => 'f') : array('file' => $c['dir'].'/src/includes/s2member.js', 'render' => TRUE), ); if(!$pro_file) $sources = (array)apply_filters('ws_plugin__s2member_static_js_sources', $sources, get_defined_vars()); if($pro_file || $combine) { $source_count = count($sources); $sources = (array)apply_filters('ws_plugin__s2member_static_pro_js_sources', $sources, get_defined_vars()); if(!$page_text && c_ws_plugin__s2member_utils_conds::pro_is_installed() && count($sources) === $source_count) { $pro_output = self::static_js_builtin_pro_output(); if($pro_output === '') return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'No compatible static JavaScript source is available from the installed s2Member Pro version'); $sources[] = array('contents' => $pro_output); } } if(!$sources) return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'No static JavaScript sources are available for '.$id); return array('ok' => TRUE, 'sources' => $sources, 'minify' => !empty($o['static_js_minify']), 'error' => ''); } return array('ok' => FALSE, 'sources' => array(), 'minify' => FALSE, 'error' => 'Invalid static asset type'); } /** * Replaces marked PHP interpolations in a canonical JavaScript source with compact data-map slots. * * The current canonical sources place every marked interpolation inside a single-quoted JavaScript * string. Replacing only the PHP block with `'+d[n]+'` preserves that historical string coercion. * * @package s2Member\Utilities * @since 260906.0738 * * @param string $source Canonical mixed JS/PHP source. * @param string $data_map_path Shipped data-map path. * @param string $data_key Browser namespace key (`f` or `p`). * @return array Transform result. */ protected static function static_js_data_source($source = '', $data_map_path = '', $data_key = '') { $data_map = self::static_js_data_map($data_map_path); if(empty($data_map['ok'])) return array('ok' => FALSE, 'source' => '', 'error' => (string)$data_map['error']); if(!preg_match('/^[a-z][a-z0-9_]*$/i', (string)$data_key)) return array('ok' => FALSE, 'source' => '', 'error' => 'Invalid JavaScript data namespace key'); $slots = $data_map['slots']; $errors = array(); $source = preg_replace_callback('/<\\?php.*?\\?>/s', function($match) use ($slots, &$errors) { if(!preg_match('/\\/\\*d\\*\\/(.*?)\\/\\*b\\*\\//s', $match[0], $data_match)) { $errors[] = 'An unmarked PHP interpolation remains in a static JavaScript data source'; return $match[0]; } $expression = trim((string)$data_match[1]); if(!isset($slots[$expression])) { $errors[] = 'A marked JavaScript expression is missing from its shipped data map'; return $match[0]; } return "'+d[".(int)$slots[$expression]."]+'"; }, (string)$source); if($errors || strpos($source, '') !== FALSE) return array('ok' => FALSE, 'source' => '', 'error' => ($errors) ? implode('; ', array_unique($errors)) : 'PHP remained after static JavaScript data transformation'); //260906.0738 Keep the short alias lexical to this source so Framework and Pro slots cannot overwrite one another in separate or combined files. return array('ok' => TRUE, 'source' => "(function(d){\n".$source."\n})(window.s2_data.".$data_key.");", 'error' => ''); } /** * Includes one trusted shipped static JavaScript data map in normal WordPress page context. * * @package s2Member\Utilities * @since 260906.0738 * * @param string $path Data-map path. * @param array|null $keys Optional stable slot IDs; NULL evaluates every value in the data map. * @return array|false Data-map values, or FALSE on failure. */ protected static function load_static_js_data_map($path = '', $keys = NULL) { if(!$path || !is_readable($path)) return FALSE; $s2_data_keys = (is_array($keys)) ? array_fill_keys(array_map('intval', $keys), TRUE) : NULL; $data = include $path; return (is_array($data)) ? $data : FALSE; } /** * Returns page-local JavaScript data for the active generated static files. * * Complete data maps are emitted for now. TO-DO: pass page-specific sparse slot sets once feature requirements can be determined safely. * * @package s2Member\Utilities * @since 260906.0738 * * @param array $assets Active generated JavaScript assets keyed by logical filename. * @return string Inline JavaScript, or an empty string when data-map loading fails. */ public static function static_js_inline_data($assets = array()) { if(self::static_js_text_delivery() !== 'page') return ''; $paths = array(); foreach(array_keys((array)$assets) as $id) foreach(self::static_js_data_map_paths($id) as $key => $path) $paths[$key] = $path; if(!$paths) return ''; $data = array(); foreach($paths as $key => $path) { $data_map = self::load_static_js_data_map($path); if($data_map === FALSE) return ''; $data[$key] = $data_map; } $json = wp_json_encode($data); if(!is_string($json) || $json === '') return ''; //260906.0738 wp_add_inline_script() prints this in HTML; neutralize user-translatable closing-script sequences just like existing inline current-user globals. $inline = 'window.s2_data='.str_ireplace(' $build) if(in_array($id, array('s2member.css', 's2member-pro.css', 's2member.js', 's2member-pro.js'), TRUE) && ($build = abs((int)$build))) { $type = substr(strrchr($id, '.'), 1); $base = substr($id, 0, -strlen('.'.$type)); $protected[$dir.'/'.$base.'-'.$build.'.'.$type] = TRUE; } if($new_path) $protected[(string)$new_path] = TRUE; $groups = array(); foreach(array('css', 'js') as $type) foreach((array)glob($dir.'/s2member*.'.$type) as $path) if(preg_match('/\/(s2member(?:-pro)?)-\d+\.(css|js)\z/', str_replace('\\', '/', $path), $match)) $groups[$match[1].'.'.$match[2]][] = $path; $removed = array(); //260907.2203 Collect only files actually removed so cleanup logging stays accurate. foreach($groups as $paths) { usort($paths, function($a, $b) { return (int)@filemtime($b) - (int)@filemtime($a); }); $stale_kept = 0; foreach($paths as $old_path) { if(isset($protected[$old_path])) continue; $stale_kept++; if((int)@filemtime($old_path) < time() - 30 * DAY_IN_SECONDS || $stale_kept > 10) if(@unlink($old_path)) $removed[] = basename($old_path); } } //260907.2203 Record cleanup only when stale files were actually deleted. if($removed) c_ws_plugin__s2member_utils_logs::log_entry('css-js', array('event' => 'Stale Static CSS/JS files pruned', 'result' => 'success', 'removed' => $removed)); return; } /** * Builds one timestamped CSS/JS file in the WordPress uploads tree. * * @package s2Member\Utilities * @since 260903.0525 * * @param string $id Stable asset identifier. * @param int $build Timestamp used in the generated filename. * @param string $type `css` or `js`. * @param array $sources Ordered source definitions/files. * @param bool $minify Whether generated output should be minified. * @return array Build result with URL/path or error. */ protected static function build_static_asset($id = '', $build = 0, $type = '', $sources = array(), $minify = FALSE) { $id = trim(preg_replace('/[^a-z0-9_\-]/i', '-', (string)$id), '-'); $type = strtolower((string)$type); if(!$id || !$build || !in_array($type, array('css', 'js'), TRUE) || !$sources) return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'Invalid generated asset parameters'); $location = self::static_assets_location(TRUE); if(empty($location['ok'])) return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => $location['error']); $filename = $id.'-'.(int)$build.'.'.$type; $path = $location['dir'].'/'.$filename; $url = $location['url'].'/'.$filename; $headers = array(); $body = ''; foreach($sources as $source) { $source = is_array($source) ? $source : array('file' => $source); if(array_key_exists('contents', $source)) $chunk = (string)$source['contents']; else if(empty($source['file']) || !is_readable($source['file'])) return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'Source file is not readable: '.((!empty($source['file'])) ? $source['file'] : '(missing path)')); else if(!empty($source['data_map'])) { if(($chunk = file_get_contents($source['file'])) === FALSE) return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'Could not read source file: '.$source['file']); $transformed = self::static_js_data_source($chunk, (string)$source['data_map'], (!empty($source['data_key'])) ? (string)$source['data_key'] : ''); if(empty($transformed['ok'])) return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => (string)$transformed['error'].' in '.$source['file']); $chunk = $transformed['source']; } else if(!empty($source['render'])) { $template_vars = (!empty($source['vars']) && is_array($source['vars'])) ? $source['vars'] : array(); extract($template_vars, EXTR_SKIP); ob_start(); include $source['file']; $chunk = ob_get_clean(); } else if(($chunk = file_get_contents($source['file'])) === FALSE) return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'Could not read source file: '.$source['file']); if(preg_match('/\A\s*(\/\*\*.*?\*\/)\s*/s', $chunk, $match)) { if(!empty($source['preserve_header'])) { //260905.0106 Preserve the complete notice in a compact wrapped block instead of a huge original banner or an unreadable single line. $header = self::preserved_asset_header($match[1]); if(!in_array($header, $headers, TRUE)) $headers[] = $header; } $chunk = preg_replace('/\A\s*\/\*\*.*?\*\/\s*/s', '', $chunk, 1); } if(!empty($source['replacements']) && is_array($source['replacements'])) $chunk = str_replace(array_keys($source['replacements']), array_values($source['replacements']), $chunk); $body .= "\n".((!empty($source['prefix'])) ? $source['prefix']."\n" : '').$chunk.((!empty($source['suffix'])) ? "\n".$source['suffix'] : ''); } //260906.2219 Personal/member globals are page-specific by design and must never be written into a publicly cacheable static JavaScript file. if($type === 'js' && preg_match('/\bS2MEMBER_CURRENT_USER_[A-Z0-9_]+\s*=(?!=)/', $body)) return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'Page-specific member globals cannot be stored in static JavaScript'); try { $body = ($minify) ? (($type === 'css') ? self::compress_css($body) : self::compress_js($body)) : trim($body); } catch(Exception $e) { return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'JavaScript minification failed: '.$e->getMessage()); } $marker = self::static_asset_marker_output($id, $type, $build); $output = (($headers) ? implode("\n", $headers)."\n" : '').$body."\n".$marker."\n"; $tmp = $path.'.tmp-'.uniqid('', TRUE); if(file_put_contents($tmp, $output, LOCK_EX) === FALSE || (!@rename($tmp, $path) && !is_file($path))) { @unlink($tmp); return array('ok' => FALSE, 'url' => '', 'path' => '', 'error' => 'Could not write generated asset: '.$path); } @unlink($tmp); return array('ok' => TRUE, 'url' => $url, 'path' => $path, 'error' => ''); } /** * Resolves the writable/public directory used for generated static frontend assets. * * @package s2Member\Utilities * @since 260903.0437 * * @param bool $for_write Create/validate the directory for a write operation. * @return array Location result. */ protected static function static_assets_location($for_write = FALSE) { $key = ($for_write) ? 'write' : 'read'; if(isset(self::$static_assets_location_cache[$key])) return self::$static_assets_location_cache[$key]; $uploads = wp_upload_dir(NULL, (bool)$for_write); if(!empty($uploads['error']) || empty($uploads['basedir']) || empty($uploads['baseurl'])) 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'] : '')); $dir = untrailingslashit((string)apply_filters('ws_plugin__s2member_static_assets_dir', trailingslashit($uploads['basedir']).'s2member-assets', $uploads)); $url = untrailingslashit((string)apply_filters('ws_plugin__s2member_static_assets_url', trailingslashit($uploads['baseurl']).'s2member-assets', $uploads)); if(!$dir || !$url) return self::$static_assets_location_cache[$key] = array('ok' => FALSE, 'dir' => '', 'url' => '', 'error' => 'The static-assets directory or URL filter returned an empty value'); if($for_write) { if(!is_dir($dir) && !wp_mkdir_p($dir)) return self::$static_assets_location_cache[$key] = array('ok' => FALSE, 'dir' => $dir, 'url' => $url, 'error' => 'Could not create static-assets directory: '.$dir); if(!is_writable($dir)) return self::$static_assets_location_cache[$key] = array('ok' => FALSE, 'dir' => $dir, 'url' => $url, 'error' => 'Static-assets directory is not writable: '.$dir); //260905.0158 Discourage casual directory listing without adding executable PHP to the public uploads directory. $index_file = $dir.'/index.html'; if(!is_file($index_file)) @file_put_contents($index_file, ''."\n", LOCK_EX); } return self::$static_assets_location_cache[$key] = array('ok' => TRUE, 'dir' => $dir, 'url' => $url, 'error' => ''); } /** * Handles CSS compression of hex colors. * * @package s2Member\Utilities * @since 3.5 * * @param array $m Array of matches from ``preg_replace_callback()``. * @return string Shortened hex code when possible, full hex code otherwise. */ public static function _compress_css_c3($m = FALSE) { if($m[2][0] === $m[2][1] && $m[2][2] === $m[2][3] && $m[2][4] === $m[2][5]) return $m[1].$m[2][0].$m[2][2].$m[2][4]; return $m[0]; } /** * JShrink 1.8.1 adaptation used for generated JavaScript minification. * * JShrink is Copyright (c) Robert Hafner and licensed under BSD-3-Clause. * See `/src/licensing/jshrink.txt` for the complete license and attribution. * @see https://github.com/tedious/JShrink * * The upstream parser is kept intentionally isolated behind `jshrink_*` names. * The only PHP 5.6 compatibility change avoids PHP 7.1+ negative string offsets * when tracking the last character. * * @package s2Member\Utilities * @since 260903.0437 */ protected $jshrink_input; protected $jshrink_len = 0; protected $jshrink_index = 0; protected $jshrink_a = ''; protected $jshrink_b = ''; protected $jshrink_c; protected $jshrink_last_char; protected $jshrink_output = ''; protected $jshrink_options = array(); protected $jshrink_string_delimiters = array("'" => TRUE, '"' => TRUE, '`' => TRUE); protected $jshrink_no_new_line_characters = array('(' => TRUE, '-' => TRUE, '+' => TRUE, '[' => TRUE, '#' => TRUE, '@' => TRUE); protected static $jshrink_default_options = array('flaggedComments' => TRUE); protected static $jshrink_keywords = array('delete', 'do', 'for', 'in', 'instanceof', 'return', 'typeof', 'yield'); protected $jshrink_max_keyword_len = 0; protected $jshrink_locks = array(); protected function jshrink_minify_to_string($js, $options) { $this->jshrink_initialize($js, $options); $this->jshrink_loop(); $output = $this->jshrink_output; $this->jshrink_clean(); return $output; } protected function jshrink_initialize($js, $options) { $this->jshrink_options = array_merge(self::$jshrink_default_options, $options); $this->jshrink_input = $js.PHP_EOL; $this->jshrink_len = strlen($this->jshrink_input); $this->jshrink_a = "\n"; $this->jshrink_b = "\n"; $this->jshrink_last_char = "\n"; $this->jshrink_output = ''; $this->jshrink_max_keyword_len = max(array_map('strlen', self::$jshrink_keywords)); } protected function jshrink_echo($char) { $this->jshrink_output .= $char; //260903.0437 JShrink 1.8.1 uses `$char[-1]`; `substr()` preserves that behavior on s2Member's PHP 5.6 minimum. $this->jshrink_last_char = substr($char, -1); } protected function jshrink_loop() { while($this->jshrink_a !== FALSE && !is_null($this->jshrink_a) && $this->jshrink_a !== '') { switch($this->jshrink_a) { case "\r": case "\n": if($this->jshrink_b !== FALSE && isset($this->jshrink_no_new_line_characters[$this->jshrink_b])) { $this->jshrink_echo($this->jshrink_a); $this->jshrink_save_string(); break; } if($this->jshrink_b === ' ') break; case ' ': if(self::jshrink_is_alphanumeric($this->jshrink_b)) $this->jshrink_echo($this->jshrink_a); $this->jshrink_save_string(); break; default: switch($this->jshrink_b) { case "\r": case "\n": if(strpos('}])+-"\'', $this->jshrink_a) !== FALSE) { $this->jshrink_echo($this->jshrink_a); $this->jshrink_save_string(); break; } else if(self::jshrink_is_alphanumeric($this->jshrink_a)) { $this->jshrink_echo($this->jshrink_a); $this->jshrink_save_string(); } break; case ' ': if(!self::jshrink_is_alphanumeric($this->jshrink_a)) break; default: if($this->jshrink_a === '/' && ($this->jshrink_b === "'" || $this->jshrink_b === '"')) { $this->jshrink_save_regex(); continue 3; } $this->jshrink_echo($this->jshrink_a); $this->jshrink_save_string(); break; } } $this->jshrink_b = $this->jshrink_get_real(); if($this->jshrink_b == '/') { $valid_tokens = "(,=:[!&|?\n"; $last_token = ($this->jshrink_a == ' ') ? $this->jshrink_last_char : $this->jshrink_a; if(strpos($valid_tokens, $last_token) !== FALSE || $this->jshrink_ends_in_keyword()) $this->jshrink_save_regex(); } } } protected function jshrink_clean() { unset($this->jshrink_input, $this->jshrink_c, $this->jshrink_options); $this->jshrink_len = $this->jshrink_index = 0; $this->jshrink_a = $this->jshrink_b = ''; $this->jshrink_output = ''; } protected function jshrink_get_char() { if(isset($this->jshrink_c)) { $char = $this->jshrink_c; unset($this->jshrink_c); } else { $char = ($this->jshrink_index < $this->jshrink_len) ? $this->jshrink_input[$this->jshrink_index] : FALSE; if($char === FALSE) return FALSE; $this->jshrink_index++; } if($char == "\r") $char = "\n"; if($char !== "\n" && $char < "\x20") return ' '; return $char; } protected function jshrink_peek() { if($this->jshrink_index >= $this->jshrink_len) return FALSE; $char = $this->jshrink_input[$this->jshrink_index]; if($char == "\r") $char = "\n"; if($char !== "\n" && $char < "\x20") return ' '; return $char; } protected function jshrink_get_real() { $start_index = $this->jshrink_index; $char = $this->jshrink_get_char(); if($char !== '/') return $char; $this->jshrink_c = $this->jshrink_get_char(); if($this->jshrink_c === '/') { $this->jshrink_process_one_line_comments($start_index); return $this->jshrink_get_real(); } else if($this->jshrink_c === '*') { $this->jshrink_process_multi_line_comments($start_index); return $this->jshrink_get_real(); } return $char; } protected function jshrink_process_one_line_comments($start_index) { $third = ($this->jshrink_index < $this->jshrink_len) ? $this->jshrink_input[$this->jshrink_index] : FALSE; $this->jshrink_get_next("\n"); unset($this->jshrink_c); if($third == '@') { $end = $this->jshrink_index - $start_index; $this->jshrink_c = "\n".substr($this->jshrink_input, $start_index, $end); } } protected function jshrink_process_multi_line_comments($start_index) { $this->jshrink_get_char(); $third = $this->jshrink_get_char(); if($third == '*' && $this->jshrink_peek() == '/') { $this->jshrink_index++; return; } if($this->jshrink_get_next('*/')) { $this->jshrink_get_char(); $this->jshrink_get_char(); $char = $this->jshrink_get_char(); if((!empty($this->jshrink_options['flaggedComments']) && $third === '!') || $third === '@') { if($start_index > 0) { $this->jshrink_echo($this->jshrink_a); $this->jshrink_a = ' '; if($this->jshrink_input[$start_index - 1] === "\n") $this->jshrink_echo("\n"); } $end = ($this->jshrink_index - 1) - $start_index; $this->jshrink_echo(substr($this->jshrink_input, $start_index, $end)); $this->jshrink_c = $char; return; } } else $char = FALSE; if($char === FALSE) throw new RuntimeException('Unclosed multiline comment at position: '.($this->jshrink_index - 2)); $this->jshrink_c = $char; } protected function jshrink_get_next($string) { $pos = strpos($this->jshrink_input, $string, $this->jshrink_index); if($pos === FALSE) return FALSE; $this->jshrink_index = $pos; return ($this->jshrink_index < $this->jshrink_len) ? $this->jshrink_input[$this->jshrink_index] : FALSE; } protected function jshrink_save_string() { $start = $this->jshrink_index; $this->jshrink_a = $this->jshrink_b; if(!isset($this->jshrink_string_delimiters[$this->jshrink_a])) return; $type = $this->jshrink_a; $this->jshrink_echo($this->jshrink_a); while(($this->jshrink_a = $this->jshrink_get_char()) !== FALSE) { switch($this->jshrink_a) { case $type: break 2; case "\n": if($type === '`') $this->jshrink_echo($this->jshrink_a); else throw new RuntimeException('Unclosed string at position: '.$start); break; case '\\': $this->jshrink_b = $this->jshrink_get_char(); if($this->jshrink_b !== "\n") $this->jshrink_echo($this->jshrink_a.$this->jshrink_b); break; default: $this->jshrink_echo($this->jshrink_a); } } } protected function jshrink_save_regex() { if($this->jshrink_a != ' ') $this->jshrink_echo($this->jshrink_a); $this->jshrink_echo($this->jshrink_b); $character_class = FALSE; $character_class_index = NULL; while(($this->jshrink_a = $this->jshrink_get_char()) !== FALSE) { if($this->jshrink_a === '/' && !$character_class) break; if($this->jshrink_a === '[') { $character_class = TRUE; $character_class_index = $this->jshrink_index; } else if($this->jshrink_a === ']') $character_class = FALSE; if($this->jshrink_a === '\\') { $this->jshrink_echo($this->jshrink_a); $this->jshrink_a = $this->jshrink_get_char(); } if($this->jshrink_a === "\n") { if($character_class) throw new RuntimeException('Unclosed character class at position: '.$character_class_index); throw new RuntimeException('Unclosed regex pattern at position: '.$this->jshrink_index); } $this->jshrink_echo($this->jshrink_a); } $this->jshrink_b = $this->jshrink_get_real(); } protected static function jshrink_is_alphanumeric($char) { return preg_match('/^[\w\$\pL]$/', $char) === 1 || $char == '/'; } protected function jshrink_ends_in_keyword() { $test = substr($this->jshrink_output.$this->jshrink_a, -1 * ($this->jshrink_max_keyword_len + 10)); foreach(self::$jshrink_keywords as $keyword) if(preg_match('/[^\w]'.$keyword.'[ ]?$/i', $test) === 1) return TRUE; return FALSE; } protected function jshrink_lock($js) { $lock = '"LOCK---'.crc32(time()).'"'; $matches = array(); preg_match('/([+-])(\s+)([+-])/S', $js, $matches); if(empty($matches)) return $js; $this->jshrink_locks[$lock] = $matches[2]; return preg_replace('/([+-])\s+([+-])/S', '$1'.$lock.'$2', $js); } protected function jshrink_unlock($js) { foreach($this->jshrink_locks as $lock => $replacement) $js = str_replace($lock, $replacement, $js); return $js; } } }