$source One of the request superglobals. * @param array{name: string, default?: string} $options Named read options. * @return string */ public static function readText(array $source, array $options): string { return self::readSanitized($source, array( 'name' => $options['name'], 'default' => $options['default'] ?? '', 'sanitizer' => 'sanitize_text_field', )); } /** * One multi-line request field, unslashed and then sanitized. * Same contract as readText(), but preserves newlines. * * @param array $source One of the request superglobals. * @param array{name: string, default?: string} $options Named read options. * @return string */ public static function readTextarea(array $source, array $options): string { return self::readSanitized($source, array( 'name' => $options['name'], 'default' => $options['default'] ?? '', 'sanitizer' => 'sanitize_textarea_field', )); } /** * Shared body of readText()/readTextarea(): unslash first, sanitize * second, never the other way around. * * @param array $source * @param array{name: string, default: string, sanitizer: string} $options * @return string */ private static function readSanitized(array $source, array $options): string { $name = $options['name']; $default = $options['default']; $sanitizer = $options['sanitizer']; if (!array_key_exists($name, $source) || !is_scalar($source[$name])) { return $default; } $unslashed = self::normalizeScalar($source[$name]); if (!function_exists($sanitizer)) { // Only reachable where WordPress core itself is unavailable // (sanitize_text_field exists since 2.9, sanitize_textarea_field // since 4.7). Unslashing still happened, so returning the value // degrades rather than silently dropping the user's input. return $unslashed; } $clean = $sanitizer($unslashed); return is_string($clean) ? $clean : $default; } /** * Read a named request value under a byte ceiling and decode it as JSON. * * The size is measured on the RAW superglobal, before wp_unslash() or * sanitize_text_field() touches it, and an oversized payload is refused * without either. Reading through getPostOrGetSanitize() first and bounding * afterwards inverts the point of the ceiling: a megabyte of observations * was fully unslashed and sanitized and only then rejected for being too * big, so the limit cost more on the input it exists to refuse than on the * ones it accepts. * * @param array{name: string, max_bytes: int, unavailable_label: string} $options * @return array{status: 'available', observations: array}|array{ * status: 'unavailable', * unavailable: array{code: string, message: string, payloadBytes: int, maxBytes: int} * } */ public static function decodeBoundedJsonRequestValue(array $options): array { $bounded = self::readBoundedRaw($options['name'], $options['max_bytes']); return self::decodeBoundedJsonArray(array( 'raw' => $bounded['value'], 'raw_bytes' => $bounded['bytes'], 'max_bytes' => $options['max_bytes'], 'unavailable_label' => $options['unavailable_label'], )); } /** * One request value, measured first and normalized only if it fits. * * @return array{value: string, bytes: int} `value` is empty when the raw * payload exceeded the ceiling; `bytes` is always the real raw length, so * the caller can report what the client sent. */ private static function readBoundedRaw(string $name, int $maxBytes): array { $maxBytes = max(1, $maxBytes); $raw = isset($_GET[$name]) ? $_GET[$name] : (isset($_POST[$name]) ? $_POST[$name] : null); if ($raw === null || !is_scalar($raw)) { return array('value' => '', 'bytes' => 0); } $rawString = (string)$raw; $bytes = strlen($rawString); if ($bytes > $maxBytes) { return array('value' => '', 'bytes' => $bytes); } // safeWpUnslash() is declared mixed because it forwards whatever // wp_unslash() returns; a scalar in yields a string out, and falling // back to the raw value keeps this total rather than assuming it. $unslashed = self::safeWpUnslash($rawString); $unslashedString = is_string($unslashed) ? $unslashed : $rawString; $clean = function_exists('sanitize_text_field') ? sanitize_text_field($unslashedString) : $unslashedString; return array('value' => is_string($clean) ? $clean : '', 'bytes' => $bytes); } /** * Decode a size-bounded JSON array without truncating valid input into an * invalid document or silently converting parse failure to an empty array. * * `raw_bytes` overrides the measured length of `raw`, for a caller that * already refused an oversized payload WITHOUT normalizing it (see * decodeBoundedJsonRequestValue below). Such a caller passes an empty * `raw` with the real size, so the refusal still reports what the client * actually sent rather than zero. * * @param array{raw: string, max_bytes: int, unavailable_label: string, raw_bytes?: int} $options * @return array{status: 'available', observations: array}|array{ * status: 'unavailable', * unavailable: array{code: string, message: string, payloadBytes: int, maxBytes: int} * } */ public static function decodeBoundedJsonArray(array $options): array { $raw = $options['raw']; $maxBytes = max(1, $options['max_bytes']); $label = $options['unavailable_label']; $payloadBytes = array_key_exists('raw_bytes', $options) ? (int)$options['raw_bytes'] : strlen($raw); if ($payloadBytes === 0) { $code = 'payload_missing'; $message = $label . ' payload is missing.'; } elseif ($payloadBytes > $maxBytes) { $code = 'payload_truncated'; $message = $label . ' payload truncated at ' . $maxBytes . ' bytes.'; } else { $decoded = json_decode($raw, true); if (json_last_error() !== JSON_ERROR_NONE) { $code = 'invalid_json'; $message = $label . ' JSON is invalid (' . json_last_error_msg() . ').'; } elseif (!is_array($decoded)) { $code = 'invalid_shape'; $message = $label . ' JSON must decode to an array.'; } else { return array('status' => 'available', 'observations' => $decoded); } } return array( 'status' => 'unavailable', 'unavailable' => array( 'code' => $code, 'message' => $message, 'payloadBytes' => $payloadBytes, 'maxBytes' => $maxBytes, ), ); } /** * Normalize and sanitize feedback issue selections from request data. * * @param mixed $issuesRaw * @return array */ public static function sanitizeFeedbackIssues($issuesRaw): array { $issuesRaw = self::safeWpUnslash($issuesRaw); if (!is_array($issuesRaw)) { $issuesRaw = array($issuesRaw); } $issues = array(); foreach ($issuesRaw as $issue) { if (!is_scalar($issue)) { continue; } $clean = sanitize_text_field((string)$issue); if ($clean !== '') { $issues[] = $clean; } } return $issues; } /** * Read one request parameter, unslashed and sanitized. * * wp_magic_quotes() slash-escapes every superglobal at boot and * sanitize_text_field() does not undo it, so an unslashed read hands back * {\"v\":1,...} for JSON and O\'Brien for a search. Hence core's * sanitize_text_field( wp_unslash( ... ) ) order. * * Called from {@see ABJ_404_Solution_Functions::getPostOrGetSanitize()}, * which stays the public entry point so DI-based test doubles can * substitute request values without touching real superglobals. * * @param string $name The key to retrieve the value for. * @param string|null $defaultValue The value to return if the value is not set. * @return string The sanitized value. */ public static function getPostOrGetSanitize($name, $defaultValue = null) { $returnValue = isset($_GET[$name]) ? $_GET[$name] : (isset($_POST[$name]) ? $_POST[$name] : null); if ($returnValue === null && $name === 'action') { $returnValue = isset($_GET['abj404action']) ? $_GET['abj404action'] : (isset($_POST['abj404action']) ? $_POST['abj404action'] : null); } $returnValue = self::applyBulkActionFallback($name, $returnValue); if ($returnValue !== null) { $returnValue = self::safeWpUnslash($returnValue); if (is_array($returnValue)) { $returnValue = array_map('sanitize_text_field', $returnValue); } else { $returnValue = sanitize_text_field($returnValue); } } $finalValue = $returnValue ?? $defaultValue; return is_string($finalValue) ? $finalValue : (is_string($defaultValue) ? $defaultValue : ''); } /** * Native WP_List_Table renders bulk-action