self::MAX_ENTRIES ) { $entries = array_slice( $entries, -self::MAX_ENTRIES ); } if ( null === get_option( self::OPTION, null ) ) { return (bool) add_option( self::OPTION, $entries, '', false ); } return (bool) update_option( self::OPTION, $entries, false ); } /** * Returns the stored entries, newest first. * * @param int $limit Maximum number of entries; 0 = all. * @return array[] */ public static function get( int $limit = 0 ): array { $entries = array_reverse( self::stored() ); if ( $limit > 0 ) { $entries = array_slice( $entries, 0, $limit ); } return $entries; } /** * Deletes every entry. * * @return void */ public static function clear(): void { delete_option( self::OPTION ); } /** * Number of stored entries. * * @return int */ public static function count(): int { return count( self::stored() ); } /** * Normalises one entry; null when it has no usable code. * * @param mixed $entry Raw entry. * @return array|null */ public static function normalize( $entry ) { if ( ! is_array( $entry ) || ! isset( $entry['code'] ) || ! is_string( $entry['code'] ) ) { return null; } $code = substr( sanitize_key( $entry['code'] ), 0, self::MAX_CODE_CHARS ); if ( '' === $code ) { return null; } $time = ( isset( $entry['time'] ) && is_numeric( $entry['time'] ) ) ? (int) $entry['time'] : time(); if ( $time <= 0 ) { $time = time(); } $status = ( isset( $entry['status'] ) && is_numeric( $entry['status'] ) ) ? (int) $entry['status'] : 0; if ( $status < 0 || $status > 599 ) { $status = 0; } $message = ''; if ( isset( $entry['message'] ) && ( is_string( $entry['message'] ) || is_numeric( $entry['message'] ) ) ) { $message = self::truncate( sanitize_text_field( (string) $entry['message'] ), self::MAX_MESSAGE_CHARS ); } $nav = ''; if ( isset( $entry['nav'] ) && is_string( $entry['nav'] ) && in_array( $entry['nav'], self::NAV_TYPES, true ) ) { $nav = $entry['nav']; } return array( 'time' => $time, 'code' => $code, 'path' => self::clean_path( isset( $entry['path'] ) ? $entry['path'] : '' ), 'status' => $status, 'message' => $message, 'nav' => $nav, ); } /** * Reduces a value to a root-relative path without query or fragment. * * @param mixed $path Raw path. * @return string '' when not a usable path. */ public static function clean_path( $path ): string { if ( ! is_string( $path ) ) { return ''; } $path = trim( $path ); $path = substr( $path, 0, strcspn( $path, '?#' ) ); // RFC 3986 path characters only; browsers percent-encode everything else. $path = (string) preg_replace( '#[^A-Za-z0-9\-._~!$&\'()*+,;=:@/%]#', '', $path ); if ( '' === $path || '/' !== $path[0] ) { return ''; } // Collapse leading slashes so the path can never become protocol-relative. $path = '/' . ltrim( $path, '/' ); return substr( $path, 0, self::MAX_PATH_CHARS ); } /** * Reads and validates the stored list (oldest first). * * @return array[] */ private static function stored(): array { $raw = get_option( self::OPTION, array() ); if ( ! is_array( $raw ) ) { return array(); } $entries = array(); foreach ( $raw as $entry ) { $clean = self::normalize( $entry ); if ( null !== $clean ) { $entries[] = $clean; } } if ( count( $entries ) > self::MAX_ENTRIES ) { $entries = array_slice( $entries, -self::MAX_ENTRIES ); } return $entries; } /** * Multibyte-safe truncation. * * @param string $value Text. * @param int $length Maximum characters. * @return string */ private static function truncate( string $value, int $length ): string { if ( strlen( $value ) <= $length ) { return $value; } if ( function_exists( 'mb_substr' ) ) { return trim( (string) mb_substr( $value, 0, $length, 'UTF-8' ) ); } return trim( substr( $value, 0, $length ) ); } }