injectedLogging = $logging; $this->injectedRequestContext = $requestContext; $this->mbAdapter = $mbAdapter !== null ? $mbAdapter : (extension_loaded('mbstring') ? ABJ_404_Solution_MbStringAdapterMb::getInstance() : ABJ_404_Solution_MbStringAdapterPreg::getInstance()); $this->regexHelper = $regexHelper !== null ? $regexHelper : (extension_loaded('mbstring') ? ABJ_404_Solution_RegexHelperMb::getInstance() : ABJ_404_Solution_RegexHelperPreg::getInstance()); } /** @return self */ public static function getInstance() { if (self::$instance !== null) { return self::$instance; } // If the DI container is initialized, prefer it. if (class_exists('ABJ_404_Solution_ServiceContainer')) { $service = ABJ_404_Solution_ServiceContainer::safeGet('functions'); if ($service instanceof self) { self::$instance = $service; return self::$instance; } } self::$instance = new self(); return self::$instance; } /** * Returns the polymorphic mbstring/preg adapter. Useful for callers * that only need the string primitives and want to depend on a smaller * interface than ABJ_404_Solution_Functions. * * @return ABJ_404_Solution_MbStringAdapter */ public function getMbStringAdapter() { return $this->mbAdapter; } /** * Returns the polymorphic regex helper. Useful for callers that only * need the regex primitives and want to depend on a smaller interface * than ABJ_404_Solution_Functions. * * @return ABJ_404_Solution_RegexHelper */ public function getRegexHelper() { return $this->regexHelper; } /** * Returns the injected Logging service, falling back to the locator * when constructed outside the DI container (early boot / tests). * Production paths go through DI via the bootstrap factory. * * @return ABJ_404_Solution_Logging */ protected function logging() { if ($this->injectedLogging !== null) { return $this->injectedLogging; } return abj_service('logging'); } /** * Returns the injected RequestContext, falling back to the locator * when constructed outside the DI container (early boot / tests). * * @return ABJ_404_Solution_RequestContext */ protected function requestContext() { if ($this->injectedRequestContext !== null) { return $this->injectedRequestContext; } return abj_service('request_context'); } /** Uses explode() to return an array. * @param string $string * @return array */ function explodeNewline(string $string): array { $normalized = str_replace("\r\n", "\n", $string); $normalized = str_replace('\n', "\n", $normalized); $result = array_filter(explode("\n", $this->strtolower($normalized)), array($this, 'removeEmptyCustom')); return $result; } /** * Like {@see explodeNewline()} but tolerates comma-separated input. * Use for textarea-backed settings whose values are slug-like tokens * (post type names, taxonomy slugs) that cannot legitimately contain * commas. A site owner who types "post,page,product" instead of one * per line should not silently break the suggestion engine (i321). * * Do NOT use for free-form values where commas are valid content * (User-Agent strings, regex patterns, paths). Those callers must * keep {@see explodeNewline()}. * * @param string $string * @return array */ function explodeNewlineOrComma(string $string): array { $normalized = str_replace("\r\n", "\n", $string); $normalized = str_replace('\n', "\n", $normalized); $normalized = str_replace(',', "\n", $normalized); $result = array_filter( array_map('trim', explode("\n", $this->strtolower($normalized))), array($this, 'removeEmptyCustom') ); return $result; } /** * @param string|array $needle * @param string|array|null $replacement * @param string $haystack * @return string */ function str_replace($needle, $replacement, string $haystack): string { if ($replacement === null) { $replacement = ''; } /** @var string $result */ $result = str_replace($needle, $replacement, $haystack); return $result; } /** * @param string $needle * @param string $replacement * @param string $haystack * @return string */ function single_str_replace(string $needle, string $replacement, string $haystack): string { if ($haystack == "" || $this->strlen($haystack) == 0) { return ""; } else if ($needle === '' || $this->strpos($haystack, $needle) === false) { return $haystack; } $splitResult = explode($needle, $haystack); $implodeResult = implode($replacement, $splitResult); return $implodeResult; } /** Hash the last octet of an IP address. * @param string $ip * @return string */ function md5lastOctet($ip) { if (trim($ip) == "") { return $ip; } $partsToStrip = 1; $separatorChar = "."; // split into parts $parts = explode(".", $ip); if (count($parts) == 1) { $parts = explode(":", $ip); // if exploding on : worked then assume we have an IPv6. if (count($parts) > 1) { $partsToStrip = max(count($parts) - 3, 1); $separatorChar = ":"; } } $firstPart = implode($separatorChar, array_slice($parts, 0, count($parts) - $partsToStrip)); $partToHash = $parts[count($parts) - $partsToStrip]; $lastPart = $separatorChar . substr(base_convert(md5($partToHash), 16,32), 0, 12); return $firstPart . $lastPart; } // ========================================================================= // mbstring / preg primitives - delegated to ABJ_404_Solution_MbStringAdapter // ========================================================================= /** @return int */ function ord(string $char): int { return $this->mbAdapter->ord($char); } /** @return string */ function strtolower(string $string): string { return $this->mbAdapter->strtolower($string); } /** @return int */ function strlen(string $string): int { return $this->mbAdapter->strlen($string); } /** @return int|false */ function strpos(string $haystack, string $needle, int $offset = 0) { return $this->mbAdapter->strpos($haystack, $needle, $offset); } /** @return string */ function substr(?string $str, int $start, ?int $length = null): string { return $this->mbAdapter->substr($str, $start, $length); } /** * @param string|null $string * @return string */ function sanitizeInvalidUTF8(?string $string): string { return $this->mbAdapter->sanitizeInvalidUTF8($string); } // ========================================================================= // Regex primitives - delegated to ABJ_404_Solution_RegexHelper // ========================================================================= /** * @param string $pattern * @param string $string * @param array|null $regs * @return bool|int */ function regexMatch(string $pattern, string $string, ?array &$regs = null) { return $this->regexHelper->regexMatch($pattern, $string, $regs); } /** * @param string $pattern * @param string $string * @param array|null $regs * @return bool|int */ function regexMatchi(string $pattern, string $string, ?array &$regs = null) { return $this->regexHelper->regexMatchi($pattern, $string, $regs); } /** * @param string $pattern * @param string $replacement * @param string $string * @return string|null */ function regexReplace($pattern, $replacement, $string) { return $this->regexHelper->regexReplace($pattern, $replacement, $string); } /** Used with array_filter() * @param string $value * @return boolean */ function removeEmptyCustom($value) { if ($value == null) { return false; } return trim($value) !== ''; } /** * @return float|string */ function getExecutionTime() { $startTime = $this->requestContext()->process_start_time; if ($startTime !== null) { $elapsedTime = abj_clock()->nowFloat() - $startTime; return $elapsedTime; } return ''; } /** * Expand the plugin's known constants (`{ABJ404_STATUS_AUTO}`, `{PLUGIN_NAME}`, * `{ABJ404_VERSION}`, ...) in a TEMPLATE. Never translates anything, which makes it * the token pass that is safe to run over SQL text. * * ORDER MATTERS: call this on the template BEFORE any user-controlled value is bound * into it. The pass cannot tell template text from data, so a value that reaches it * containing `{ABJ404_VERSION}` is rewritten too. For SQL, expand tokens and table * names first and bind data last (`$wpdb->prepare`, or the `query_params` option of * `queryAndGetResults()`, which binds after table expansion). For HTML use * {@see renderTemplate()}, which does both steps in the safe order. * * @param string $text The template. Not data. * @return string The template with every known constant token replaced. */ public function replaceKnownConstants(string $text): string { global $wpdb; // known strings that do not exist in the translation file. $knownReplacements = array( '{ABJ404_STATUS_AUTO}' => ABJ404_STATUS_AUTO, '{ABJ404_STATUS_MANUAL}' => ABJ404_STATUS_MANUAL, '{ABJ404_STATUS_CAPTURED}' => ABJ404_STATUS_CAPTURED, '{ABJ404_STATUS_IGNORED}' => ABJ404_STATUS_IGNORED, '{ABJ404_STATUS_LATER}' => ABJ404_STATUS_LATER, '{ABJ404_STATUS_REGEX}' => ABJ404_STATUS_REGEX, '{ABJ404_TYPE_404_DISPLAYED}' => ABJ404_TYPE_404_DISPLAYED, '{ABJ404_TYPE_POST}' => ABJ404_TYPE_POST, '{ABJ404_TYPE_CAT}' => ABJ404_TYPE_CAT, '{ABJ404_TYPE_TAG}' => ABJ404_TYPE_TAG, '{ABJ404_TYPE_EXTERNAL}' => ABJ404_TYPE_EXTERNAL, '{ABJ404_TYPE_HOME}' => ABJ404_TYPE_HOME, '{ABJ404_HOME_URL}' => ABJ404_HOME_URL, '{PLUGIN_NAME}' => PLUGIN_NAME, '{ABJ404_VERSION}' => ABJ404_VERSION, '{PHP_VERSION}' => phpversion(), '{WP_VERSION}' => get_bloginfo('version'), '{MYSQL_VERSION}' => $wpdb->db_version(), '{ABJ404_MAX_AJAX_DROPDOWN_SIZE}' => ABJ404_MAX_AJAX_DROPDOWN_SIZE, '{WP_MEMORY_LIMIT}' => WP_MEMORY_LIMIT, '{MBSTRING}' => extension_loaded('mbstring') ? 'true' : 'false', ); // replace known strings that do not exist in the translation file. return $this->str_replace(array_keys($knownReplacements), array_values($knownReplacements), $text); } /** * Render an HTML template: expand constants, translate every `{msgid}` token, and * substitute the caller's values, in that order and in a single pass over the values. * * WHY THE ORDER IS THE CONTRACT. Constants and translations are expanded over the * TEMPLATE, and `$vars` are inserted LAST with one `strtr()`. The previous shape * (`str_replace(keys, values, $template)` and then a `{msgid}` pass over the finished * string) ran the translation pass over user data. A visitor who requested * `/a{Remove Later Status} onmouseover=alert(1) x` had that token replaced by the * site's UNESCAPED translation inside the `title="..."` attribute of the admin table * (a ru_RU msgstr holds a bare `"`), which breaks out of the attribute. Because * `strtr()` never rescans text it has already substituted, a value that itself * contains another var key, a constant token or a `{msgid}` is inserted verbatim. * * Translations are NOT escaped here: some msgids intentionally carry markup * (`{View the debug file.}`, ``, `<ol>`). The fix is ordering, not * blanket escaping. Escape each VALUE for its context (`esc_html`, `esc_attr`) before * putting it in `$vars`. * * A token whose text is exactly a key of `$vars` is a value slot: it is never handed * to `translate()`, so a var key that happens to look like a msgid * (`{View the debug file.}`) is filled with its value, not its translation. * An untranslated `{x}` token renders as `x` (braces stripped), as it always did. * * A value that is a fragment of markup carrying its own `{msgid}` tokens (a button, * a status label) must be rendered by its own renderTemplate() call first and passed * in as the finished string; this method never translates values. * * @param string $template Template text. Never data. * @param array $vars Map of slot token (usually `'{name}'`) to value. * Values are cast to string; empty keys are ignored. * @return string The rendered markup. */ public function renderTemplate(string $template, array $vars = array()): string { $stringVars = array(); foreach ($vars as $slot => $value) { $slot = (string)$slot; // strtr() with an empty key returns false on PHP 7.4 and ignores it on 8.x. if ($slot === '') { continue; } $stringable = is_scalar($value) || $value === null || (is_object($value) && method_exists($value, '__toString')); $stringVars[$slot] = $stringable ? (string)$value : ''; } $text = $this->replaceKnownConstants($template); $translated = preg_replace_callback('/\{(.+?)\}/', static function (array $match) use ($stringVars): string { if (isset($stringVars[$match[0]])) { return $match[0]; } // External HTML template placeholders are extracted and checked by // HtmlTemplateTranslationCoverageTest because they do not live in PHP call sites. return function_exists('translate') ? (string)translate($match[1], '404-solution') : $match[1]; }, $text); if ($translated === null) { // PCRE failure (backtrack or JIT stack limit on a huge template). Render the // untranslated template rather than an empty page. $this->logging()->warn('renderTemplate: the translation pass failed (preg_last_error() = ' . preg_last_error() . '); rendering the template untranslated.'); $translated = $text; } return $stringVars === array() ? $translated : strtr($translated, $stringVars); } /** * @param string $haystack * @param string $needle * @return bool */ function endsWithCaseInsensitive(string $haystack, string $needle): bool { $length = $this->strlen($needle); if ($this->strlen($haystack) < $length) { return false; } $lowerNeedle = $this->strtolower($needle); $lowerHay = $this->strtolower($haystack); return ($this->substr($lowerHay, -$length) == $lowerNeedle); } /** * @param string $haystack * @param string $needle * @return bool */ function endsWithCaseSensitive(string $haystack, string $needle): bool { $length = $this->strlen($needle); if ($this->strlen($haystack) < $length) { return false; } return ($this->substr($haystack, -$length) == $needle); } }