Skip to content

Commit 0d3a555

Browse files
RivianTrackrclaude
andcommitted
Code review follow-ups: security, a11y, ops polish (1.47.0)
Security - Atomic rate-limit helper (wp_cache_incr path, transient fallback) replaces the read-then-write race in review submissions. - Public AJAX endpoints (rtg_get_tires, rtg_get_filter_options, rtg_track_click, rtg_track_search) are now rate limited per fingerprint in their own buckets. Analytics drops silently when throttled so user typing isn't penalized; reviews keep the tight 3/5min ceiling. - submit_guest_tire_rating trims guest_name before the empty check so whitespace-only names are rejected. - roamer_assign / roamer_hide / roamer_restore now validate every ID against RTG_Database::validate_tire_id() and cap the batch at 50. UX / a11y - Cards render immediately; ratings swap in on XHR resolution instead of blocking the first paint. - Server pagination shows a dimmed, input-blocking overlay only after 500ms so fast responses don't flash. - Mobile filter drawer moves keyboard focus to the first control on open. - Switch-slider toggles replace inline onclick= with data-toggle-target + a delegated listener in rivian-tires.js. Ops - Admin gets an HTML email when cron Roamer sync fails (wp_error / non-200 / bad JSON), throttled to once per 12h per reason. Manual admin runs don't email (errors already surface in the UI). Honors the existing roamer_notify_enabled setting. Code health - RTG_Ajax::ALLOWED_SORTS centralizes the sort whitelist. - Admin dashboard "missing links/images" strings now go through _n(). - Removed stale "AI" comment + production console.time in search.js. - New PHPUnit test covers rate-limit overflow behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0b1dea2 commit 0d3a555

16 files changed

Lines changed: 424 additions & 128 deletions

CHANGELOG.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,65 @@ 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.47.0] - 2026-04-20
8+
9+
### Security
10+
- **Rate limiting hardened end-to-end.** Replaced the read-then-write
11+
transient pattern with an atomic `wp_cache_add` / `wp_cache_incr` path
12+
when a persistent object cache is available, falling back to transients
13+
otherwise. Rate-limit counters no longer drift under concurrent writes
14+
when Redis/Memcached is present.
15+
- **Public AJAX endpoints now rate limited.** `rtg_get_tires`,
16+
`rtg_get_filter_options`, `rtg_track_click`, and `rtg_track_search`
17+
previously relied only on a nonce that is exposed in page source, so a
18+
scraper could harvest it and hammer the endpoint. Reads now cap at 120
19+
req/min per fingerprint; analytics caps at 240 req/min and drops silently
20+
when throttled so user typing isn't penalized. Reviews keep the existing
21+
tighter 3-per-5-minute limit in its own bucket so normal browsing can't
22+
starve review submissions or vice versa.
23+
- **Guest name validation now trims first.** `submit_guest_tire_rating`
24+
rejected `""` but accepted `" "` — whitespace-only submissions are now
25+
rejected with the same error.
26+
- **Roamer assign/hide/restore batches now validate each tire ID** against
27+
the canonical regex and cap the batch at 50 entries. A malformed JSON
28+
payload can no longer slip a bad ID into `update_tire()`.
29+
30+
### Added
31+
- **Admin email on Roamer sync failure.** When a scheduled cron sync can't
32+
reach the feed (wp_error, non-200, or invalid JSON), the admin gets an
33+
HTML email with the failure reason and a link to the Roamer Sync page.
34+
Throttled to one email per reason per 12h so an extended outage doesn't
35+
flood the inbox. Manual admin runs don't email (errors already show in
36+
the UI). Honors the existing `roamer_notify_enabled` setting.
37+
- **Slow-response loading state for server-side pagination.** `#tireCards`
38+
gets a dimmed, input-blocking overlay after 500ms of fetch latency so
39+
fast responses don't flash a spinner but slow ones give feedback.
40+
- **Rate-limit concurrency test.** New PHPUnit test issues max + 2 rapid
41+
guest submissions and asserts the overflow is blocked.
42+
43+
### Changed
44+
- **Ratings no longer block card render.** `loadTireRatings` now runs in
45+
parallel with `renderCards`, and rating blocks are swapped in via
46+
`updateRatingDisplay` once the batch response arrives. Saves ~200ms of
47+
perceived latency on the first paint for each page.
48+
- **Switch-slider toggles no longer use inline `onclick`.** The 3PMS/OEM
49+
filter pills now use `data-toggle-target` + a delegated listener wired
50+
up in `rivian-tires.js`, which plays nicer with strict CSP headers.
51+
- **Mobile filter drawer manages keyboard focus on open.** The first
52+
focusable control inside `#mobileFilterContent` receives focus when the
53+
drawer opens, matching the WCAG 2.1 focus-management pattern used by
54+
the other modals/drawers.
55+
- **Admin dashboard widget strings are now `_n()`-translatable.** "Missing
56+
links / missing images" copy goes through `_n()` + `__()` so translators
57+
can pluralize properly.
58+
- **Sort whitelist centralized.** `RTG_Ajax::ALLOWED_SORTS` is the single
59+
source of truth for the sort keys accepted by `get_tires()`.
60+
61+
### Removed
62+
- **Stale AI references in frontend code.** `search.js` no longer mentions
63+
the removed "AI" button in its module docstring; the leftover
64+
`console.time('Building search index')` production log is gone.
65+
766
## [1.46.0] - 2026-04-15
867

968
### Removed

frontend/css/rivian-tires.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2874,6 +2874,17 @@ a.rtg-user-review-tire-link:hover {
28742874
}
28752875
}
28762876

2877+
/* Server-pagination slow-response indicator.
2878+
Applied to #tireCards after a 500ms delay so fast responses don't flash. */
2879+
.rtg-cards-loading {
2880+
opacity: 0.55;
2881+
pointer-events: none;
2882+
transition: opacity 0.2s ease;
2883+
}
2884+
@media (prefers-reduced-motion: reduce) {
2885+
.rtg-cards-loading { transition: none; }
2886+
}
2887+
28772888
/* === Accessibility: Focus Styles === */
28782889
.info-tooltip-trigger:focus-visible {
28792890
outline: 2px solid var(--rtg-accent);

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/filters.js

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { rtgIcon, safeString, getDOMElement, debounce, updateSliderBackground, s
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';
12-
import { loadTireRatings } from './ratings.js';
12+
import { loadTireRatings, updateRatingDisplay } from './ratings.js';
1313
import { isPreciseMatch } from './search.js';
1414
import { isServerSide, serverSideFilterAndRender } from './server.js';
1515

@@ -234,27 +234,35 @@ function render() {
234234

235235
const tireIds = visible.map(row => row[0]).filter(Boolean);
236236

237-
loadTireRatings(tireIds).then(() => {
238-
renderCards(visible);
239-
renderPaginationControls(state.filteredRows);
240-
241-
const noResults = getDOMElement("noResults");
242-
const tireCards = getDOMElement("tireCards");
243-
if (state.filteredRows.length === 0) {
244-
renderSmartNoResults();
245-
noResults.style.display = "block";
246-
tireCards.style.display = "none";
247-
} else {
248-
noResults.style.display = "none";
249-
tireCards.style.display = "grid";
250-
}
237+
// Kick off ratings fetch in parallel — don't block the card render on it.
238+
// Cards render immediately with a "No reviews" placeholder, and we swap in
239+
// the real rating display once the batch response arrives. Sort-by-rating
240+
// paths load ratings earlier in filterAndRender(), so they're already warm.
241+
const ratingsPromise = loadTireRatings(tireIds);
242+
243+
renderCards(visible);
244+
renderPaginationControls(state.filteredRows);
245+
246+
const noResults = getDOMElement("noResults");
247+
const tireCards = getDOMElement("tireCards");
248+
if (state.filteredRows.length === 0) {
249+
renderSmartNoResults();
250+
noResults.style.display = "block";
251+
tireCards.style.display = "none";
252+
} else {
253+
noResults.style.display = "none";
254+
tireCards.style.display = "grid";
255+
}
251256

252-
if ('requestIdleCallback' in window) {
253-
requestIdleCallback(() => preloadNextPageImages());
254-
} else {
255-
setTimeout(preloadNextPageImages, 100);
256-
}
257+
ratingsPromise.then(() => {
258+
tireIds.forEach(id => updateRatingDisplay(id));
257259
});
260+
261+
if ('requestIdleCallback' in window) {
262+
requestIdleCallback(() => preloadNextPageImages());
263+
} else {
264+
setTimeout(preloadNextPageImages, 100);
265+
}
258266
}
259267

260268
// Delegated once at module load: every click inside #paginationControls

frontend/js/modules/search.js

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44
* Smart search — index building, fuzzy matching, and button-based search.
55
*
66
* The user types a query and explicitly clicks the "Search" button (or
7-
* presses Enter) to filter locally, or clicks the "AI" button to get
8-
* AI-powered recommendations.
7+
* presses Enter) to filter the local tire list.
98
*/
109

1110
import { state } from './state.js';
@@ -23,8 +22,6 @@ let searchIndex = {
2322
};
2423

2524
export function buildSearchIndex() {
26-
console.time('Building search index');
27-
2825
Object.values(searchIndex).forEach(index => index.clear());
2926

3027
state.allRows.forEach((row, rowIndex) => {

frontend/js/modules/server.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,19 @@ export function fetchTiresFromServer(page) {
5151
body.append('sort', sortMap[sortVal] || sortVal);
5252

5353
const tireCountEl = getDOMElement("tireCount");
54-
if (tireCountEl) tireCountEl.textContent = 'Loading...';
54+
const tireCardsEl = getDOMElement("tireCards");
55+
56+
// Only surface a loading state if the request is genuinely slow (>500ms).
57+
// Fast responses shouldn't flash a spinner/overlay.
58+
const loadingTimer = setTimeout(() => {
59+
if (tireCountEl) tireCountEl.textContent = 'Loading...';
60+
if (tireCardsEl) tireCardsEl.classList.add('rtg-cards-loading');
61+
}, 500);
62+
63+
const clearLoadingState = () => {
64+
clearTimeout(loadingTimer);
65+
if (tireCardsEl) tireCardsEl.classList.remove('rtg-cards-loading');
66+
};
5567

5668
return fetch(rtgData.settings.ajaxurl, {
5769
method: 'POST',
@@ -60,6 +72,7 @@ export function fetchTiresFromServer(page) {
6072
})
6173
.then(res => res.json())
6274
.then(json => {
75+
clearLoadingState();
6376
if (!json.success) {
6477
console.error('Server tire fetch failed:', json);
6578
return;
@@ -148,6 +161,7 @@ export function fetchTiresFromServer(page) {
148161
loadTireRatings(tireIds);
149162
})
150163
.catch(err => {
164+
clearLoadingState();
151165
if (err.name !== 'AbortError') console.error('Fetch error:', err);
152166
});
153167
}

frontend/js/rivian-tires.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,9 +420,28 @@ document.addEventListener("DOMContentLoaded", () => {
420420
const badge = toggleBtn.querySelector('.mobile-filter-badge');
421421
const badgeHTML = badge ? ` <span class="mobile-filter-badge">${badge.textContent}</span>` : '';
422422
toggleBtn.innerHTML = `<i class="fa-solid fa-sliders" aria-hidden="true"></i>&nbsp; ${isOpen ? "Hide" : "Show"} Filters${badgeHTML}`;
423+
424+
// Move keyboard focus into the drawer on open (WCAG 2.1 focus mgmt).
425+
if (isOpen) {
426+
const firstFocusable = filterContent.querySelector(
427+
'select, input:not([type="hidden"]), button, [tabindex]:not([tabindex="-1"])'
428+
);
429+
if (firstFocusable && typeof firstFocusable.focus === 'function') {
430+
firstFocusable.focus({ preventScroll: true });
431+
}
432+
}
423433
});
424434
}
425435

436+
// Wire switch-slider proxy clicks (keyboard + pointer) to their checkbox.
437+
// Replaces legacy inline onclick= handlers for accessibility.
438+
document.querySelectorAll('.switch-slider[data-toggle-target]').forEach(slider => {
439+
const targetId = slider.dataset.toggleTarget;
440+
const target = targetId ? document.getElementById(targetId) : null;
441+
if (!target) return;
442+
slider.addEventListener('click', () => target.click());
443+
});
444+
426445
const trigger = getDOMElement("wheelDrawerTrigger");
427446
const drawer = getDOMElement("wheelDrawer");
428447
const wheelCallout = getDOMElement("wheelDrawerContainer");

frontend/js/rivian-tires.min.js

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/templates/tire-guide.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@
8282
</div>
8383
</span>
8484
<input type="checkbox" id="filter3pms" aria-label="3PMS Rated" />
85-
<span class="switch-slider" onclick="document.getElementById('filter3pms').click()"></span>
85+
<span class="switch-slider" data-toggle-target="filter3pms"></span>
8686
</div>
8787
<div class="switch-label">
8888
<span class="switch-text">
@@ -94,7 +94,7 @@
9494
</div>
9595
</span>
9696
<input type="checkbox" id="filterOEM" aria-label="OEM"/>
97-
<span class="switch-slider" onclick="document.getElementById('filterOEM').click()"></span>
97+
<span class="switch-slider" data-toggle-target="filterOEM"></span>
9898
</div>
9999
</div>
100100
</div>

includes/class-rtg-admin.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,18 @@ public function render_dashboard_widget() {
126126
echo '<p style="margin:4px 0;color:#666;">';
127127
$notices = array();
128128
if ( $missing_links > 0 ) {
129-
$notices[] = sprintf( '%d missing links', $missing_links );
129+
$notices[] = sprintf(
130+
/* translators: %d is the number of tires missing affiliate links. */
131+
_n( '%d missing link', '%d missing links', $missing_links, 'rivian-tire-guide' ),
132+
$missing_links
133+
);
130134
}
131135
if ( $missing_images > 0 ) {
132-
$notices[] = sprintf( '%d missing images', $missing_images );
136+
$notices[] = sprintf(
137+
/* translators: %d is the number of tires missing images. */
138+
_n( '%d missing image', '%d missing images', $missing_images, 'rivian-tire-guide' ),
139+
$missing_images
140+
);
133141
}
134142
echo esc_html( implode( ' · ', $notices ) );
135143
echo '</p>';

0 commit comments

Comments
 (0)