*/ const STATUS_ORDER = [ 'critical' => 0, 'warning' => 1, 'unverified' => 2, 'ok' => 3, 'info' => 4, 'off' => 5, ]; /** Group a row falls into when it names none. */ const DEFAULT_GROUP = 'security_modules'; /** Group id for rows naming a group that is not declared. */ const OTHER_GROUP = 'other'; /** Caps on module-supplied text, in characters. */ const MAX_LABEL = 80; const MAX_VALUE = 160; const MAX_MESSAGE = 240; const MAX_LINK = 40; const MAX_DETAIL = 160; const MAX_ID_LENGTH = 64; /** Cap on a row's fingerprint, and on a dismissal note, in characters. */ const MAX_FINGERPRINT = 64; const MAX_NOTE = 200; /** Cap on rows accepted from one filter run. */ const MAX_MODULE_ROWS = 50; /** * Normalize one row to the contract, or return null if it must be dropped. * * Fields: `id` (required; lower-cased, stripped to [a-z0-9_-]), `label` * (required; plain text), `status` (see STATUSES; unknown becomes `info`), * `value`, `message`, `last_activity` (Unix time), `url` + `link_label` * (one optional action link), `group`, `priority` (lower first), the * optional detail lines `current`, `recommended`, `reason`, and an optional * `fingerprint` (see `fingerprint()`; absent means a constant). All text is * stripped of markup and capped; the renderer escapes it. * * @param mixed $item Raw row. * @param int $order Registration order, used as the final tiebreaker. * @param string $default_group Group used when the row names none. * @param bool $internal True only for rows Site Health builds itself: allows the * `locked` flag (a muted Pro upsell row with a lock icon and * no status badge), a `note` (one extra detail line shown on * any status) and `message_code` (the part of the message to show in * monospace, such as a file mode). Module rows can never set these. * * @return array|null */ public static function normalize_item( $item, int $order, string $default_group = self::DEFAULT_GROUP, bool $internal = false ): ?array { if ( ! is_array( $item ) ) { return null; } $id = substr( preg_replace( '/[^a-z0-9_\-]/', '', strtolower( self::scalar_string( $item['id'] ?? '' ) ) ), 0, self::MAX_ID_LENGTH ); $label = self::plain_text( $item['label'] ?? '', self::MAX_LABEL ); if ( '' === $id || '' === $label ) { return null; } $status = self::scalar_string( $item['status'] ?? 'info' ); $group = preg_replace( '/[^a-z0-9_\-]/', '', strtolower( self::scalar_string( $item['group'] ?? '' ) ) ); $last_activity = isset( $item['last_activity'] ) && is_numeric( $item['last_activity'] ) ? (int) $item['last_activity'] : 0; $row = [ 'id' => $id, 'label' => $label, 'status' => in_array( $status, self::STATUSES, true ) ? $status : 'info', 'value' => self::plain_text( $item['value'] ?? '', self::MAX_VALUE ), 'message' => self::plain_text( $item['message'] ?? '', self::MAX_MESSAGE ), 'last_activity' => $last_activity > 0 ? $last_activity : 0, 'url' => self::scalar_string( $item['url'] ?? '' ), 'link_label' => self::plain_text( $item['link_label'] ?? '', self::MAX_LINK ), 'group' => '' !== $group ? $group : $default_group, 'priority' => (int) ( $item['priority'] ?? 10 ), 'current' => self::plain_text( $item['current'] ?? '', self::MAX_DETAIL ), 'recommended' => self::plain_text( $item['recommended'] ?? '', self::MAX_DETAIL ), 'reason' => self::plain_text( $item['reason'] ?? '', self::MAX_DETAIL ), 'order' => $order, ]; $fingerprint = self::plain_text( $item['fingerprint'] ?? '', self::MAX_FINGERPRINT ); if ( '' !== $fingerprint ) { $row['fingerprint'] = $fingerprint; } if ( $internal && ! empty( $item['locked'] ) ) { $row['locked'] = true; } if ( $internal && isset( $item['message_code'] ) ) { $code = self::plain_text( $item['message_code'], 16 ); if ( '' !== $code ) { $row['message_code'] = $code; } } if ( $internal && isset( $item['note'] ) ) { $note = self::plain_text( $item['note'], self::MAX_DETAIL ); if ( '' !== $note ) { $row['note'] = $note; } } return $row; } /** * Collect rows that modules report through a filter. * * Every callback hooked to `$filter` runs inside its own try/catch for * `Throwable`, so one module that throws yields a single `unverified` row * for it and never breaks the tab or the other modules. Returned rows are * validated, de-duplicated (first id wins), capped and sorted. * * @param string $filter Filter name. * @param string $default_group Group for rows that name none. * * @return array Normalized rows. */ public static function collect( string $filter, string $default_group = self::DEFAULT_GROUP ): array { $raw = []; foreach ( self::run_callbacks_isolated( $filter, $default_group ) as $item ) { $raw[] = $item; } $rows = []; $seen = []; foreach ( array_values( $raw ) as $order => $item ) { $row = self::normalize_item( $item, $order, $default_group ); if ( null === $row || isset( $seen[ $row['id'] ] ) ) { continue; } $seen[ $row['id'] ] = true; $rows[] = $row; if ( count( $rows ) >= self::MAX_MODULE_ROWS ) { break; } } return self::sort( $rows ); } /** * Order rows by status (problems first), then priority, then registration * order. The `order` field is kept so callers can merge and re-sort. * * @param array $rows Normalized rows. * * @return array */ public static function sort( array $rows ): array { usort( $rows, static function ( array $a, array $b ): int { return [ self::STATUS_ORDER[ $a['status'] ] ?? 99, $a['priority'], $a['order'] ] <=> [ self::STATUS_ORDER[ $b['status'] ] ?? 99, $b['priority'], $b['order'] ]; } ); return $rows; } /** * Arrange sorted rows into display sections. * * Groups are declared once by the caller (`id`, `title`, `description`, * `order`, `collapsed`, optional `layout`). Rows naming an undeclared group * land in a trailing "Other" section so a row added through a filter never * disappears. Sections with no rows are omitted. * * @param array $rows Normalized, sorted rows. * @param array $groups Group metadata keyed by id. * * @return array Sections: the group metadata plus `rows`. */ public static function group_rows( array $rows, array $groups ): array { $sections = []; foreach ( $groups as $id => $group ) { $sections[ $id ] = array_merge( [ 'title' => '', 'description' => '', 'order' => 100, 'collapsed' => false, 'layout' => 'rows', ], $group, [ 'id' => $id, 'rows' => [], ] ); } foreach ( $rows as $row ) { $target = isset( $sections[ $row['group'] ] ) ? $row['group'] : self::OTHER_GROUP; if ( ! isset( $sections[ $target ] ) ) { $sections[ $target ] = [ 'id' => self::OTHER_GROUP, 'title' => __( 'Other', 'admin-optimizer' ), 'description' => '', 'order' => PHP_INT_MAX, 'collapsed' => false, 'layout' => 'rows', 'rows' => [], ]; } $sections[ $target ]['rows'][] = $row; } $sections = array_filter( $sections, static function ( array $section ): bool { return ! empty( $section['rows'] ); } ); uasort( $sections, static function ( array $a, array $b ): int { return $a['order'] <=> $b['order']; } ); return array_values( $sections ); } /** * Return cached results, building and caching them when missing. * * @param string $transient Transient key. * @param int $ttl Lifetime in seconds. * @param callable $builder Returns the array of normalized rows. * * @return array{items:array,generated_at:int} */ public static function get_results( string $transient, int $ttl, callable $builder ): array { $cached = get_transient( $transient ); if ( is_array( $cached ) && isset( $cached['items'], $cached['generated_at'] ) && is_array( $cached['items'] ) ) { return $cached; } $results = [ 'items' => $builder(), 'generated_at' => time(), ]; set_transient( $transient, $results, $ttl ); return $results; } /** * A short fingerprint of the data that makes up a finding, for a row's * `fingerprint` field. A dismissal holds only while the fingerprint is * unchanged, so a changed finding is treated as new. A row that declares * none has the constant fingerprint '' (the state is the finding). * * @param mixed $data Scalars or arrays (key order is ignored). * * @return string 12 hex characters. */ public static function fingerprint( $data ): string { $sort = static function ( $value ) use ( &$sort ) { if ( is_array( $value ) ) { $value = array_map( $sort, $value ); // Inline list check: array_is_list() needs PHP 8.1 / WP 6.5. if ( $value && array_keys( $value ) !== range( 0, count( $value ) - 1 ) ) { ksort( $value ); } } return $value; }; return substr( md5( (string) wp_json_encode( $sort( $data ) ) ), 0, 12 ); } /** * Whether a row may be dismissed: Recommended only. Critical, Couldn't * verify, Good, Info, Off and locked rows never can. * * @param array $row Normalized row. * * @return bool */ public static function can_dismiss( array $row ): bool { return 'warning' === ( $row['status'] ?? '' ) && empty( $row['locked'] ); } /** * Overlay stored dismissals onto rows. Pure: the cached rows are never * changed, so the check keeps running and the dismissal can be applied or * lifted without a re-check. * * A dismissal applies only to a dismissible row (so a check that has since * become Critical shows as Critical) and only while the stored fingerprint * equals the row's. Applied: the row gets `dismissed` (`note`, `time`, * `user_id`). Fingerprint differs: the row gets `dismissal_changed` and is * otherwise a normal Recommended row. * * @param array $rows Normalized rows. * @param array $dismissed Stored dismissals keyed by row id. * * @return array */ public static function apply_dismissals( array $rows, array $dismissed ): array { foreach ( $rows as $i => $row ) { $entry = $dismissed[ $row['id'] ] ?? null; if ( ! is_array( $entry ) || ! self::can_dismiss( $row ) ) { continue; } if ( (string) ( $entry['fingerprint'] ?? '' ) === (string) ( $row['fingerprint'] ?? '' ) ) { $rows[ $i ]['dismissed'] = [ 'note' => self::plain_text( $entry['note'] ?? '', self::MAX_NOTE ), 'time' => (int) ( $entry['time'] ?? 0 ), 'user_id' => (int) ( $entry['user_id'] ?? 0 ), ]; } else { $rows[ $i ]['dismissal_changed'] = true; } } return $rows; } /** * Count rows for the summary line. One function, so the tab header, the * snapshot and the dashboard widget can never disagree. * * Counted: `critical`, `warning`, `unverified` and `ok`. Never counted: * `info` and `off` rows, locked (Pro upsell) rows, dismissed rows, and rows in * `$excluded_groups` (Site information). * * @param array $rows Normalized rows. * @param string[] $excluded_groups Group ids whose rows are not counted. * * @return array{critical:int,warning:int,unverified:int,ok:int} */ public static function count_statuses( array $rows, array $excluded_groups = [] ): array { $counts = [ 'critical' => 0, 'warning' => 0, 'unverified' => 0, 'ok' => 0, ]; foreach ( $rows as $row ) { $status = $row['status'] ?? ''; if ( ! isset( $counts[ $status ] ) || ! empty( $row['locked'] ) || ! empty( $row['dismissed'] ) || in_array( $row['group'] ?? '', $excluded_groups, true ) ) { continue; } ++$counts[ $status ]; } return $counts; } /** * Wording for a status badge. * * @param string $status Status slug. * * @return string */ public static function status_label( string $status ): string { switch ( $status ) { case 'ok': return __( 'Good', 'admin-optimizer' ); case 'warning': return __( 'Recommended', 'admin-optimizer' ); case 'critical': return __( 'Critical', 'admin-optimizer' ); case 'off': return __( 'Off', 'admin-optimizer' ); case 'unverified': return __( "Couldn't verify", 'admin-optimizer' ); default: return __( 'Info', 'admin-optimizer' ); } } /** * Run every callback on a filter, each isolated from the others. * * Reads the registered callbacks from `$wp_filter` rather than calling * `apply_filters()`, which would let one throwing callback abort the lot. * Each callback receives the rows gathered so far and returns the new list; * a non-array return is ignored. * * @param string $filter Filter name. * @param string $default_group Group for the placeholder row of a failing callback. * * @return array Raw rows. */ private static function run_callbacks_isolated( string $filter, string $default_group ): array { global $wp_filter; $hook = is_array( $wp_filter ?? null ) || $wp_filter instanceof \ArrayAccess ? ( $wp_filter[ $filter ] ?? null ) : null; if ( ! is_object( $hook ) || ! isset( $hook->callbacks ) || ! is_array( $hook->callbacks ) ) { return []; } $callbacks = $hook->callbacks; ksort( $callbacks, SORT_NUMERIC ); $items = []; $failed = 0; foreach ( $callbacks as $priority_callbacks ) { foreach ( $priority_callbacks as $callback ) { if ( empty( $callback['function'] ) || ! is_callable( $callback['function'] ) ) { continue; } try { $result = call_user_func( $callback['function'], $items ); if ( is_array( $result ) ) { $items = $result; } } catch ( \Throwable $e ) { ++$failed; $items[] = [ 'id' => 'callback-error-' . $failed, 'label' => self::callback_label( $callback['function'] ), 'status' => 'unverified', 'message' => __( 'This module could not report its status.', 'admin-optimizer' ), 'group' => $default_group, ]; } } } return $items; } /** * Best-effort human label for a callback that failed, from its class name. * * @param mixed $callback Callable. * * @return string */ private static function callback_label( $callback ): string { $class = ''; if ( is_array( $callback ) ) { $class = is_object( $callback[0] ?? null ) ? get_class( $callback[0] ) : (string) ( $callback[0] ?? '' ); } elseif ( is_object( $callback ) && ! ( $callback instanceof \Closure ) ) { $class = get_class( $callback ); } if ( '' === $class ) { return __( 'Security module', 'admin-optimizer' ); } $short = substr( (string) strrchr( '\\' . $class, '\\' ), 1 ); return trim( str_replace( '_', ' ', $short ) ); } /** * Cast a value to string only when it is scalar. * * @param mixed $value Value. * * @return string */ private static function scalar_string( $value ): string { return is_scalar( $value ) ? (string) $value : ''; } /** * Strip markup, collapse whitespace and cap the length. * * @param mixed $value Value. * @param int $max Maximum characters. * * @return string */ private static function plain_text( $value, int $max ): string { $text = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( self::scalar_string( $value ) ) ) ); return mb_strlen( $text ) > $max ? rtrim( mb_substr( $text, 0, $max - 1 ) ) . '…' : $text; } }