Problem
Vote de-duplication exists only in the browser. assets/public/js/script.js stores voted item IDs in localStorage (niroroadmap_votes) and disables the buttons client-side. API\Task::vote() (app/API/Task.php) has no server-side check at all: every POST /tasks/{id}/vote increments the upvote/downvote post meta.
Consequences:
- Clearing site data, using a private window, or a different browser lets one person vote unlimited times.
- Anyone can script
POST /tasks/{id}/vote in a loop and inflate any item to any number.
- Vote counts are the core signal of the plugin. If they can't be trusted, the board is worthless for prioritisation and we can't safely build features on top of them (analytics, sorting by votes, weighted votes).
- The
Limiter trait is keyed on user_id (user meta), so it can't protect anonymous visitors.
Proposal
Enforce "one vote per visitor per item" on the server, without storing personal data in the clear.
Voter identification
- Issue a random voter token in a first-party cookie (
niroroadmap_voter, 1 year, SameSite=Lax, Secure on HTTPS, HttpOnly where possible).
- As a second layer, compute
hash_hmac( 'sha256', ip . '|' . user_agent, wp_salt() ). Never store the raw IP.
- Logged-in users are identified by user ID (survives cookie loss).
Storage
- Record
item_id + voter_hash + type + timestamp. Start with a dedicated table ({prefix}niroroadmap_votes, unique index on item_id, voter_hash) created via Installer, because post meta does not scale for per-voter rows.
- Keep
upvote / downvote post meta as the cached counters (so existing queries and templates keep working).
- Provide a one-time migration that leaves existing counters untouched.
Behaviour
- Second vote on the same item by the same voter returns
409 with a clear message; the UI shows the voter's existing vote.
- Allow changing a vote (up -> down) if a setting enables it (default: off, i.e. vote is final). Changing adjusts both counters atomically.
- Add per-IP-hash rate limiting for the vote endpoint (e.g. 30 votes / 10 min) using transients.
- Return the voter's own vote state in
GET /tasks/{id} so the UI no longer depends only on localStorage (keep localStorage as a UX cache).
- Votes on non-published or password-protected items stay rejected (already handled by
get_public_task()).
- Increment counters atomically (single SQL
UPDATE ... SET meta_value = meta_value + 1 or insert-then-count) to avoid read-modify-write races in vote().
Acceptance criteria
Technical notes
- Files:
app/API/Task.php (vote(), get()), app/Controller/Common/API.php (route args and permission callback), app/Bootstrap/Installer.php (table), app/Bootstrap/Uninstaller.php/uninstall.php (cleanup), assets/public/js/script.js.
- Cache-friendly sites: the vote endpoint must not depend on a page-embedded nonce that full-page caches would serve stale. Cookie + server hash avoids that.
- GDPR: a salted hash of IP + UA is still pseudonymous. Document it, and consider making the IP layer optional via a filter (
niroroadmap_vote_fingerprint).
Out of scope
Captcha / bot scoring, weighted votes (separate issue), email-verified votes.
Priority rationale
Severe. This is an integrity hole in the main feature and a prerequisite for sorting by votes, analytics and weighted votes.
Problem
Vote de-duplication exists only in the browser.
assets/public/js/script.jsstores voted item IDs inlocalStorage(niroroadmap_votes) and disables the buttons client-side.API\Task::vote()(app/API/Task.php) has no server-side check at all: everyPOST /tasks/{id}/voteincrements theupvote/downvotepost meta.Consequences:
POST /tasks/{id}/votein a loop and inflate any item to any number.Limitertrait is keyed onuser_id(user meta), so it can't protect anonymous visitors.Proposal
Enforce "one vote per visitor per item" on the server, without storing personal data in the clear.
Voter identification
niroroadmap_voter, 1 year,SameSite=Lax,Secureon HTTPS,HttpOnlywhere possible).hash_hmac( 'sha256', ip . '|' . user_agent, wp_salt() ). Never store the raw IP.Storage
item_id + voter_hash + type + timestamp. Start with a dedicated table ({prefix}niroroadmap_votes, unique index onitem_id, voter_hash) created viaInstaller, because post meta does not scale for per-voter rows.upvote/downvotepost meta as the cached counters (so existing queries and templates keep working).Behaviour
409with a clear message; the UI shows the voter's existing vote.GET /tasks/{id}so the UI no longer depends only onlocalStorage(keeplocalStorageas a UX cache).get_public_task()).UPDATE ... SET meta_value = meta_value + 1or insert-then-count) to avoid read-modify-write races invote().Acceptance criteria
localStoragecleared.readme.txtPrivacy section updated: describe the cookie and the salted hash, and that no raw IP is stored. (Currently it states votes are stored only as plain counts and nothing is sent to the server.)Technical notes
app/API/Task.php(vote(),get()),app/Controller/Common/API.php(route args and permission callback),app/Bootstrap/Installer.php(table),app/Bootstrap/Uninstaller.php/uninstall.php(cleanup),assets/public/js/script.js.niroroadmap_vote_fingerprint).Out of scope
Captcha / bot scoring, weighted votes (separate issue), email-verified votes.
Priority rationale
Severe. This is an integrity hole in the main feature and a prerequisite for sorting by votes, analytics and weighted votes.