diff --git a/CHANGELOG.md b/CHANGELOG.md index 6852221..5bc6128 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to this project will be documented in this file. +## [1.1.2] - 2026-03-01 + +### Security + +- **WidgetRenderer regex hardening**: Combined two separate `preg_replace` calls for h2→h4 heading downgrade into a single regex that only matches `

` elements with the `wp-block-heading` class. The previous approach replaced all `

` tags unconditionally, risking mismatched tags if a widget contained non-block h2 elements. + +### Performance + +- **O(n) comment tree building** (`ContextBuilder`): Replaced O(n²) recursive scan with a parent-indexed lookup map built in a single pass. Each recursion level now iterates only direct children instead of all comments. +- **Consolidated sidebar queries** (`ContextBuilder`): Merged three separate sidebar detection branches (`is_home`, `is_page`+sidebar, `is_singular` post) into a single boolean check with one `getSidebarData()` call, eliminating up to 2 redundant calls per request. +- **Transient caching for sidebar data** (`ContextBuilder`): `getSidebarRecentPosts()` and `getSidebarTags()` results cached in WordPress transients (1 hour TTL). Invalidation hooks on `save_post` (recent posts) and `create/edit/delete_post_tag` (tags). + +### Changed + +- **Hex-to-RGB consolidation** (`functions.php`): `wp_bootstrap_hex_to_rgb()` now delegates to `wp_bootstrap_hex_to_rgb_array()` instead of duplicating hex parsing logic. Added `ctype_xdigit()` validation and return type hints to all color utility functions. + ## [1.1.1] - 2026-02-28 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 82bd93f..9f22b23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w **Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file. -Current version is **v1.1.1**. See `PLAN.md` for details. +Current version is **v1.1.2**. See `PLAN.md` for details. ## Technical Stack @@ -234,6 +234,26 @@ Build steps (in order): ## Session History +### Session 21 — v1.1.2 Security Audit & Performance Fixes (2026-03-01) + +**Completed:** Cross-theme security audit with 12 findings, all fixed. Covers WidgetRenderer regex hardening, ContextBuilder performance (O(n) comment tree, sidebar query consolidation, transient caching), and hex-to-RGB code consolidation. + +**What was changed:** + +- **WidgetRenderer regex fix** (`inc/Block/WidgetRenderer.php`): Combined two `preg_replace` calls into single regex matching `

` with `wp-block-heading` class, preventing mismatched tags. +- **O(n) comment tree** (`inc/Template/ContextBuilder.php`): Parent-indexed lookup map replaces full-scan recursion. Each level now iterates only direct children. +- **Sidebar query consolidation** (`inc/Template/ContextBuilder.php`): Three separate sidebar detection branches merged into single boolean + one `getSidebarData()` call. +- **Transient caching** (`inc/Template/ContextBuilder.php` + `functions.php`): `getSidebarRecentPosts()` and `getSidebarTags()` cached in 1-hour transients with hook-based invalidation (`save_post`, `create/edit/delete_post_tag`). +- **Hex-to-RGB consolidation** (`functions.php`): Eliminated duplicate hex parsing. Added `ctype_xdigit()` validation and type hints to all color utility functions. + +**Files modified:** + +- `inc/Block/WidgetRenderer.php` — single regex for h2→h4 +- `inc/Template/ContextBuilder.php` — O(n) tree, sidebar consolidation, transient caching +- `functions.php` — hex-to-RGB consolidation, type hints, transient invalidation hooks +- `style.css` — version bump to 1.1.2 +- `CHANGELOG.md`, `CLAUDE.md` — documentation + ### Session 20 — v1.1.1 PHPUnit Test Suite (2026-02-28) **Completed:** PHPUnit test suite with 64 unit tests and 107 assertions, CI integration, and build pipeline gating. diff --git a/functions.php b/functions.php index bb8907f..21bab1f 100644 --- a/functions.php +++ b/functions.php @@ -320,6 +320,24 @@ add_action( 'save_post_wp_global_styles', function () { delete_transient( 'wp_bootstrap_variation_css_' . md5( get_stylesheet() ) ); } ); +/** + * Invalidate sidebar transient caches when content changes. + * + * @since 1.2.0 + */ +add_action( 'save_post', function () { + delete_transient( 'wp_bootstrap_sidebar_recent_4' ); +} ); +add_action( 'create_post_tag', function () { + delete_transient( 'wp_bootstrap_sidebar_tags_15' ); +} ); +add_action( 'edit_post_tag', function () { + delete_transient( 'wp_bootstrap_sidebar_tags_15' ); +} ); +add_action( 'delete_post_tag', function () { + delete_transient( 'wp_bootstrap_sidebar_tags_15' ); +} ); + /** * Build Bootstrap surface CSS variables for a given background/foreground pair. * @@ -378,17 +396,12 @@ endif; * @return string RGB triplet (e.g. "13,110,253") or empty string on failure. */ if ( ! function_exists( 'wp_bootstrap_hex_to_rgb' ) ) : - function wp_bootstrap_hex_to_rgb( $hex ) { - $hex = ltrim( $hex, '#' ); - if ( strlen( $hex ) === 3 ) { - $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; - } - if ( strlen( $hex ) !== 6 ) { + function wp_bootstrap_hex_to_rgb( string $hex ): string { + $rgb = wp_bootstrap_hex_to_rgb_array( $hex ); + if ( ! $rgb ) { return ''; } - return hexdec( substr( $hex, 0, 2 ) ) . ',' - . hexdec( substr( $hex, 2, 2 ) ) . ',' - . hexdec( substr( $hex, 4, 2 ) ); + return implode( ',', $rgb ); } endif; @@ -403,7 +416,7 @@ endif; * @return string Resulting hex color. */ if ( ! function_exists( 'wp_bootstrap_mix_hex' ) ) : - function wp_bootstrap_mix_hex( $color1, $color2, $weight ) { + function wp_bootstrap_mix_hex( string $color1, string $color2, float $weight ): string { $c1 = wp_bootstrap_hex_to_rgb_array( $color1 ); $c2 = wp_bootstrap_hex_to_rgb_array( $color2 ); if ( ! $c1 || ! $c2 ) { @@ -425,12 +438,12 @@ endif; * @return array|false Array of [r, g, b] or false on failure. */ if ( ! function_exists( 'wp_bootstrap_hex_to_rgb_array' ) ) : - function wp_bootstrap_hex_to_rgb_array( $hex ) { + function wp_bootstrap_hex_to_rgb_array( string $hex ): array|false { $hex = ltrim( $hex, '#' ); if ( strlen( $hex ) === 3 ) { $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; } - if ( strlen( $hex ) !== 6 ) { + if ( strlen( $hex ) !== 6 || ! ctype_xdigit( $hex ) ) { return false; } return array( @@ -450,7 +463,7 @@ endif; * @return float Relative luminance. */ if ( ! function_exists( 'wp_bootstrap_relative_luminance' ) ) : - function wp_bootstrap_relative_luminance( $hex ) { + function wp_bootstrap_relative_luminance( string $hex ): float { $rgb = wp_bootstrap_hex_to_rgb_array( $hex ); if ( ! $rgb ) { return 0.0; diff --git a/inc/Block/WidgetRenderer.php b/inc/Block/WidgetRenderer.php index f620aa7..8ce5375 100644 --- a/inc/Block/WidgetRenderer.php +++ b/inc/Block/WidgetRenderer.php @@ -98,15 +98,12 @@ class WidgetRenderer return $content; } - // Replace

with

for widget headings. + // Replace

...

with

pairs. + // Single regex ensures only headings with wp-block-heading class are + // downgraded, preventing mismatched tags if a widget contains other h2s. $content = preg_replace( - '//', - '

', + '/]*)>(.*?)<\/h2>/s', + '$2', $content ); diff --git a/inc/Template/ContextBuilder.php b/inc/Template/ContextBuilder.php index e891dae..569aac8 100644 --- a/inc/Template/ContextBuilder.php +++ b/inc/Template/ContextBuilder.php @@ -58,7 +58,9 @@ class ContextBuilder $context['search_query'] = get_search_query(); } - // Sidebar layout detection. + // Sidebar: determine once whether the current page needs sidebar data. + $needsSidebar = false; + if (is_home()) { $pageId = (int) get_option('page_for_posts'); if ($pageId) { @@ -67,19 +69,14 @@ class ContextBuilder $context['layout'] = 'sidebar'; } } - $context['sidebar'] = $this->getSidebarData(); + $needsSidebar = true; + } elseif (is_singular('post')) { + $needsSidebar = true; + } elseif (is_page() && get_page_template_slug() === 'page-sidebar') { + $needsSidebar = true; } - // Sidebar data for pages using the "Page with Sidebar" template. - if (is_page()) { - $slug = get_page_template_slug(); - if ($slug === 'page-sidebar') { - $context['sidebar'] = $this->getSidebarData(); - } - } - - // Posts always get sidebar data (sidebar is the default layout). - if (is_singular('post')) { + if ($needsSidebar) { $context['sidebar'] = $this->getSidebarData(); } @@ -309,21 +306,26 @@ class ContextBuilder /** * Build a nested comment tree from flat comments. + * + * Uses a parent-indexed lookup map for O(n) performance instead of + * scanning all comments at each recursion level (O(n^2)). */ - private function buildCommentTree(array $comments, int $parentId = 0): array + private function buildCommentTree(array $comments, int $parentId = 0, ?array $index = null): array { + if ($index === null) { + $index = []; + foreach ($comments as $comment) { + $parent = (int) $comment->comment_parent; + $index[$parent][] = $comment; + } + } + $tree = []; - foreach ($comments as $comment) { - if ((int) $comment->comment_parent !== $parentId) { - continue; - } - + foreach ($index[$parentId] ?? [] as $comment) { $tree[] = [ 'id' => (int) $comment->comment_ID, - // Escape at source — comment_author is user-supplied, store as safe text. 'author' => esc_html($comment->comment_author), - // esc_url() strips dangerous schemes (javascript:, data:) and encodes for HTML. 'author_url' => esc_url($comment->comment_author_url), 'avatar_url' => get_avatar_url($comment, ['size' => 40]), 'date' => get_comment_date('', $comment), @@ -336,7 +338,7 @@ class ContextBuilder 'depth' => 1, 'max_depth' => get_option('thread_comments_depth', 5), ], $comment), - 'children' => $this->buildCommentTree($comments, (int) $comment->comment_ID), + 'children' => $this->buildCommentTree($comments, (int) $comment->comment_ID, $index), ]; } @@ -452,9 +454,17 @@ class ContextBuilder /** * Get recent posts for sidebar. + * + * Cached via transient for 1 hour; invalidated on save_post. */ private function getSidebarRecentPosts(int $count = 4): array { + $transient_key = 'wp_bootstrap_sidebar_recent_' . $count; + $cached = get_transient($transient_key); + if (false !== $cached) { + return $cached; + } + $query = new \WP_Query([ 'posts_per_page' => $count, 'orderby' => 'date', @@ -474,14 +484,23 @@ class ContextBuilder wp_reset_postdata(); } + set_transient($transient_key, $posts, HOUR_IN_SECONDS); return $posts; } /** * Get tags for sidebar tag cloud. + * + * Cached via transient for 1 hour; invalidated on tag changes. */ private function getSidebarTags(int $count = 15): array { + $transient_key = 'wp_bootstrap_sidebar_tags_' . $count; + $cached = get_transient($transient_key); + if (false !== $cached) { + return $cached; + } + $tags = get_tags([ 'number' => $count, 'orderby' => 'count', @@ -501,6 +520,7 @@ class ContextBuilder ]; } + set_transient($transient_key, $items, HOUR_IN_SECONDS); return $items; } diff --git a/style.css b/style.css index 8dd1cfb..cb46f44 100644 --- a/style.css +++ b/style.css @@ -7,7 +7,7 @@ Description: A modern WordPress Block Theme built from scratch with Bootstrap 5. Requires at least: 6.7 Tested up to: 6.7 Requires PHP: 8.3 -Version: 1.1.1 +Version: 1.1.2 License: GNU General Public License v2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html Text Domain: wp-bootstrap