Skip to content

Commit f74ac8d

Browse files
authored
Merge pull request #254 from RivianTrackr/claude/plugin-review-sweep-1.45.0
Plugin review sweep: security, performance, a11y (1.45.0)
2 parents e901fc9 + 7c4bcf6 commit f74ac8d

15 files changed

Lines changed: 434 additions & 173 deletions

CHANGELOG.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,84 @@ All notable changes to the Rivian Tire Guide plugin will be documented in this f
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

7+
## [1.45.0] - 2026-04-15
8+
9+
Plugin review sweep — security hardening, performance, and accessibility fixes
10+
across the PHP backend and JS frontend. No feature changes, no schema changes.
11+
12+
### Security
13+
- **AI API key can live in `wp-config.php`** — Define `RTG_ANTHROPIC_API_KEY` to
14+
keep the Anthropic credential out of `wp_options`. The plugin settings field
15+
still works as a fallback when the constant is not set. (`includes/class-rtg-ai.php`)
16+
- **Guest rate limiting no longer trusts raw `REMOTE_ADDR`** — Replaced the naive
17+
IP check with a per-visitor fingerprint (`md5(IP + truncated User-Agent)`) so a
18+
single spoofed IP from multiple clients can't bypass the limit. Logged-in users
19+
are fingerprinted by user ID. (`includes/class-rtg-ajax.php`)
20+
- **Admin tire-delete hardens `$_GET['tire_id']`** — Input is now unslashed,
21+
sanitized, and validated before the nonce check, rejecting malformed IDs with
22+
a `wp_die()` instead of silently building an unpredictable nonce token.
23+
(`includes/class-rtg-admin.php`)
24+
- **Security event logging** — Nonce failures and rate-limit hits now emit a
25+
compact `[RTG] {json}` line to `error_log` when `WP_DEBUG` is on, giving an
26+
audit trail without changing user-facing messages. (`includes/class-rtg-ajax.php`)
27+
28+
### Performance
29+
- **Dashboard stats are now cached**`RTG_Database::get_dashboard_stats()` runs
30+
roughly ten aggregation queries; results are memoised in a 5-minute transient
31+
and invalidated automatically by `flush_cache()` on any tire/rating write.
32+
(`includes/class-rtg-database.php`)
33+
- **N+1 eliminated in AI context build**`build_tire_context()` used to call
34+
`get_all_tires()` then a second `get_tire_ratings()` query. Added
35+
`get_tires_with_ratings()`, a single `LEFT JOIN` query returning tires with
36+
aggregated rating columns, and switched the AI path to use it. (`includes/class-rtg-database.php`, `includes/class-rtg-ai.php`)
37+
- **Link checker `set_time_limit` is bounded and guarded** — The 300s / 120s
38+
hard-coded ceilings were replaced with `BATCH_SIZE × (REQUEST_TIMEOUT + 2)`
39+
and `PROGRESS_BATCH_SIZE × (REQUEST_TIMEOUT + 2)`, wrapped in a
40+
`function_exists('set_time_limit')` check for hosts that disable it.
41+
(`includes/class-rtg-ajax.php`)
42+
- **Card cache LRU tightened to 20 entries** — Previous limit was 100 with a
43+
20-entry batch eviction; that effectively held 100 cloned DOM subtrees plus
44+
their image references in memory. Dropped to 20 with single-entry LRU eviction
45+
using `Map`'s insertion-order iteration. (`frontend/js/modules/cards.js`)
46+
- **`IntersectionObserver` is now disposable** — Added `disconnectImageObserver()`
47+
and a `pagehide` listener so the shared observer is released on teardown.
48+
(`frontend/js/modules/cards.js`)
49+
50+
### Accessibility
51+
- **Pagination controls are announced properly**`#paginationControls` now
52+
carries `role="navigation"` + `aria-label`, each button has a descriptive
53+
`aria-label`, and the page info span is a `role="status"` / `aria-live="polite"`
54+
region so screen readers announce page changes. (`frontend/js/modules/filters.js`)
55+
- **Image modal traps focus and returns focus on close** — Opening the modal
56+
remembers the launching element, focuses the dialog, traps Tab/Shift+Tab
57+
inside, and returns focus to the original element when closed. (`frontend/js/modules/image-modal.js`)
58+
59+
### Refactors
60+
- **Single-source-of-truth `RTG_Database::validate_tire_id()`** — The
61+
`preg_match('/^[a-zA-Z0-9\-_]+$/', ...) && strlen <= 50` rule was duplicated
62+
across 15+ AJAX handlers and the database helper. Extracted to one static
63+
method and threaded through all callers. (`includes/class-rtg-database.php`,
64+
`includes/class-rtg-ajax.php`, `includes/class-rtg-admin.php`)
65+
- **`get_filtered_tires()` split into builders** — Extracted
66+
`build_filter_where_clause()` and `build_filter_sort_clause()` so the main
67+
method is now a short count + fetch pair. No behaviour change, but the query
68+
body is dramatically easier to read and modify. (`includes/class-rtg-database.php`)
69+
- **Memory-safe search listener binding**`initializeSmartSearch()` used
70+
`cloneNode(true)` to strip old handlers, which orphaned references held in
71+
module-level caches. Now tracks the handler and calls `removeEventListener()`.
72+
(`frontend/js/modules/search.js`)
73+
- **Pagination event delegation** — Replaced per-render `.onclick` assignments
74+
with a single delegated click handler on `#paginationControls`, using
75+
`data-pagination="prev|next"` attributes. (`frontend/js/modules/filters.js`)
76+
- **Inline `style.cssText` replaced with CSS classes**`.info-tooltip-trigger`
77+
and `.tire-card-tag-list` now live in `rivian-tires.css`, eliminating the
78+
hardcoded style blocks and mouseenter/mouseleave handlers in `cards.js`.
79+
Hover and focus states also get proper focus-visible treatment.
80+
(`frontend/css/rivian-tires.css`, `frontend/js/modules/cards.js`)
81+
82+
### Changed
83+
- **Plugin version** — Bumped to 1.45.0.
84+
785
## [1.44.2] - 2026-04-08
886

987
### Fixed

frontend/css/rivian-tires.css

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2860,7 +2860,45 @@ a.rtg-user-review-tire-link:hover {
28602860
}
28612861
.rtg-pagination-info {
28622862
font-size: 14px;
2863-
color: var(--rtg-text-muted, #8493a5);
2863+
font-weight: 500;
2864+
display: flex;
2865+
align-items: center;
2866+
color: var(--rtg-text-primary, #e5e7eb);
2867+
}
2868+
2869+
/* Info tooltip button used inside efficiency badges and spec rows.
2870+
Previously rendered via style.cssText in JS — now a proper class. */
2871+
.info-tooltip-trigger {
2872+
background: none;
2873+
border: none;
2874+
color: var(--rtg-text-muted, #9ca3af);
2875+
font-size: 14px;
2876+
cursor: pointer;
2877+
padding: 2px;
2878+
border-radius: 50%;
2879+
width: 20px;
2880+
height: 20px;
2881+
display: flex;
2882+
align-items: center;
2883+
justify-content: center;
2884+
transition: color 0.2s ease, background-color 0.2s ease;
2885+
}
2886+
.info-tooltip-trigger:hover,
2887+
.info-tooltip-trigger:focus-visible {
2888+
color: var(--rtg-accent, #fba919);
2889+
background-color: color-mix(in srgb, var(--rtg-accent, #fba919) 10%, transparent);
2890+
}
2891+
.info-tooltip-trigger:focus-visible {
2892+
outline: 2px solid var(--rtg-accent, #fba919);
2893+
outline-offset: 2px;
2894+
}
2895+
2896+
/* Tag list row inside tire cards. */
2897+
.tire-card-tag-list {
2898+
display: flex;
2899+
flex-wrap: wrap;
2900+
gap: 4px;
2901+
justify-content: flex-end;
28642902
}
28652903

28662904
/* === Favorite Button Overlay === */

frontend/css/rivian-tires.min.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/js/modules/cards.js

Lines changed: 27 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import { state, ROWS_PER_PAGE } from './state.js';
8-
import { rtgColor, rtgIcon, escapeHTML, safeString, getDOMElement } from './helpers.js';
8+
import { rtgIcon, escapeHTML, safeString, getDOMElement } from './helpers.js';
99
import { VALIDATION_PATTERNS, NUMERIC_BOUNDS, validateNumeric, safeImageURL, safeLinkURL, safeReviewLinkURL } from './validation.js';
1010
import { TOOLTIP_DATA, createInfoTooltip } from './tooltips.js';
1111
import { createRatingHTML } from './ratings.js';
@@ -48,6 +48,23 @@ export function observeCardImages(container) {
4848
images.forEach(img => imageObserver.observe(img));
4949
}
5050

51+
/**
52+
* Disconnect the shared IntersectionObserver. Call when the guide is
53+
* being torn down or the card container is about to be cleared in bulk.
54+
*/
55+
export function disconnectImageObserver() {
56+
if (imageObserver) {
57+
imageObserver.disconnect();
58+
imageObserver = null;
59+
}
60+
}
61+
62+
// Clean up when the page is unloaded so we don't hold the observer across
63+
// page transitions in persistent-navigation setups.
64+
if (typeof window !== 'undefined') {
65+
window.addEventListener('pagehide', disconnectImageObserver, { once: true });
66+
}
67+
5168
function removeSkeletonLoader() {
5269
const skeleton = document.getElementById('rtg-skeleton-loader');
5370
if (skeleton) skeleton.remove();
@@ -364,31 +381,6 @@ export function createSingleCard(row) {
364381
infoButton.dataset.tooltipKey = 'Efficiency Score';
365382
infoButton.setAttribute('aria-label', 'More info about Efficiency Score');
366383
infoButton.setAttribute('type', 'button');
367-
infoButton.style.cssText = `
368-
background: none;
369-
border: none;
370-
color: var(--rtg-text-muted);
371-
font-size: 14px;
372-
cursor: pointer;
373-
padding: 2px;
374-
border-radius: 50%;
375-
width: 20px;
376-
height: 20px;
377-
display: flex;
378-
align-items: center;
379-
justify-content: center;
380-
transition: all 0.2s ease;
381-
`;
382-
383-
infoButton.addEventListener('mouseenter', () => {
384-
infoButton.style.color = rtgColor('accent');
385-
infoButton.style.backgroundColor = `color-mix(in srgb, ${rtgColor('accent')} 10%, transparent)`;
386-
});
387-
388-
infoButton.addEventListener('mouseleave', () => {
389-
infoButton.style.color = rtgColor('text-muted');
390-
infoButton.style.backgroundColor = 'transparent';
391-
});
392384

393385
scoreSection.appendChild(scoreText);
394386
scoreSection.appendChild(infoButton);
@@ -432,31 +424,6 @@ export function createSingleCard(row) {
432424
}
433425
roamerInfoBtn.setAttribute('aria-label', 'More info about Real-World Efficiency');
434426
roamerInfoBtn.setAttribute('type', 'button');
435-
roamerInfoBtn.style.cssText = `
436-
background: none;
437-
border: none;
438-
color: var(--rtg-text-muted);
439-
font-size: 14px;
440-
cursor: pointer;
441-
padding: 2px;
442-
border-radius: 50%;
443-
width: 20px;
444-
height: 20px;
445-
display: flex;
446-
align-items: center;
447-
justify-content: center;
448-
transition: all 0.2s ease;
449-
`;
450-
451-
roamerInfoBtn.addEventListener('mouseenter', () => {
452-
roamerInfoBtn.style.color = rtgColor('accent');
453-
roamerInfoBtn.style.backgroundColor = `color-mix(in srgb, ${rtgColor('accent')} 10%, transparent)`;
454-
});
455-
456-
roamerInfoBtn.addEventListener('mouseleave', () => {
457-
roamerInfoBtn.style.color = rtgColor('text-muted');
458-
roamerInfoBtn.style.backgroundColor = 'transparent';
459-
});
460427

461428
roamerScore.appendChild(roamerText);
462429
roamerScore.appendChild(roamerInfoBtn);
@@ -527,8 +494,7 @@ export function createSingleCard(row) {
527494
tagLabel.textContent = 'Tags';
528495

529496
const tagValue = document.createElement('span');
530-
tagValue.className = 'tire-card-spec-value';
531-
tagValue.style.cssText = 'display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end;';
497+
tagValue.className = 'tire-card-spec-value tire-card-tag-list';
532498

533499
tagList.forEach(tag => {
534500
const tagEl = document.createElement('span');
@@ -593,10 +559,14 @@ export function createSingleCard(row) {
593559

594560
card.appendChild(actionsContainer);
595561

596-
if (state.cardCache.size >= 100) {
597-
// Evict oldest 20 entries to avoid thrashing on every new card.
598-
const keysToDelete = [...state.cardCache.keys()].slice(0, 20);
599-
keysToDelete.forEach(k => state.cardCache.delete(k));
562+
// Tight LRU ceiling. Map iteration order is insertion order, so evicting
563+
// the first key removes the oldest entry. Kept small (20) because cloned
564+
// card nodes retain their subtree and we don't want them holding images
565+
// off the GC indefinitely.
566+
const CARD_CACHE_MAX = 20;
567+
if (state.cardCache.size >= CARD_CACHE_MAX) {
568+
const firstKey = state.cardCache.keys().next().value;
569+
if (firstKey !== undefined) state.cardCache.delete(firstKey);
600570
}
601571
state.cardCache.set(cacheKey, card.cloneNode(true));
602572

frontend/js/modules/filters.js

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import { state, filterIndexes, ROWS_PER_PAGE } from './state.js';
8-
import { rtgColor, rtgIcon, safeString, getDOMElement, debounce, updateSliderBackground, styleButton, escapeHTML } from './helpers.js';
8+
import { rtgIcon, safeString, getDOMElement, debounce, updateSliderBackground, styleButton, escapeHTML } from './helpers.js';
99
import { VALIDATION_PATTERNS, NUMERIC_BOUNDS, ALLOWED_SORT_OPTIONS, sanitizeInput, validateNumeric } from './validation.js';
1010
import { RTG_ANALYTICS } from './analytics.js';
1111
import { renderCards, preloadNextPageImages } from './cards.js';
@@ -269,41 +269,70 @@ function render() {
269269
});
270270
}
271271

272+
// Delegated once at module load: every click inside #paginationControls
273+
// checks for a data-pagination attribute and dispatches, so re-rendering
274+
// the pagination buttons doesn't leak handlers.
275+
let paginationDelegated = false;
276+
function ensurePaginationDelegation() {
277+
if (paginationDelegated) return;
278+
const container = getDOMElement("paginationControls");
279+
if (!container) return;
280+
paginationDelegated = true;
281+
container.addEventListener("click", (e) => {
282+
const btn = e.target.closest('[data-pagination]');
283+
if (!btn || btn.disabled) return;
284+
const action = btn.dataset.pagination;
285+
if (action === "prev" && state.currentPage > 1) {
286+
state.currentPage--;
287+
} else if (action === "next") {
288+
state.currentPage++;
289+
} else {
290+
return;
291+
}
292+
render();
293+
updateURLFromFilters();
294+
const filterTop = getDOMElement("filterTop");
295+
if (filterTop) filterTop.scrollIntoView({ behavior: "smooth" });
296+
});
297+
}
298+
272299
function renderPaginationControls(totalRows) {
273300
const container = getDOMElement("paginationControls");
274301
container.innerHTML = "";
275302
const totalPages = Math.ceil(totalRows.length / ROWS_PER_PAGE);
276-
if (totalPages <= 1) return;
303+
if (totalPages <= 1) {
304+
container.removeAttribute("role");
305+
container.removeAttribute("aria-label");
306+
return;
307+
}
308+
309+
ensurePaginationDelegation();
310+
container.setAttribute("role", "navigation");
311+
container.setAttribute("aria-label", "Tire list pagination");
277312

278313
const prev = document.createElement("button");
314+
prev.type = "button";
279315
prev.textContent = "Previous";
280316
prev.disabled = state.currentPage === 1;
317+
prev.dataset.pagination = "prev";
318+
prev.setAttribute("aria-label", `Previous page (currently on page ${state.currentPage} of ${totalPages})`);
281319
styleButton(prev);
282-
prev.onclick = () => {
283-
state.currentPage--;
284-
render();
285-
updateURLFromFilters();
286-
const filterTop = getDOMElement("filterTop");
287-
if (filterTop) filterTop.scrollIntoView({ behavior: "smooth" });
288-
};
289320
container.appendChild(prev);
290321

291322
const pageInfo = document.createElement("span");
323+
pageInfo.className = "rtg-pagination-info";
324+
pageInfo.setAttribute("role", "status");
325+
pageInfo.setAttribute("aria-live", "polite");
292326
pageInfo.textContent = `Page ${state.currentPage} of ${totalPages}`;
293-
pageInfo.style.cssText = `color: ${rtgColor('text-primary')}; font-weight: 500; display: flex; align-items: center;`;
294327
container.appendChild(pageInfo);
295328

296329
const next = document.createElement("button");
330+
next.type = "button";
297331
next.textContent = "Next";
298332
next.disabled = state.currentPage === totalPages;
333+
next.dataset.pagination = "next";
334+
next.setAttribute("aria-label", `Next page (currently on page ${state.currentPage} of ${totalPages})`);
299335
styleButton(next);
300-
next.onclick = () => {
301-
state.currentPage++;
302-
render();
303-
updateURLFromFilters();
304-
const filterTop = getDOMElement("filterTop");
305-
if (filterTop) filterTop.scrollIntoView({ behavior: "smooth" });
306-
};
307336
container.appendChild(next);
308337
}
309338

0 commit comments

Comments
 (0)