Skip to content

Commit e8a6aad

Browse files
devallibusclaude
andcommitted
refactor: use reviewer token cookie for duplicate prevention
Replace IP-based duplicate detection with an HttpOnly cookie token. The server mints a random token on first review, hashes it with SHA-256, and stores the hash. Duplicate check is now reviewer_token_hash + shader within 24 hours — different users on the same IP are no longer blocked. IP is retained only for burst rate limiting (5 reviews / 10 min). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4be9c35 commit e8a6aad

3 files changed

Lines changed: 70 additions & 13 deletions

File tree

apps/web/src/lib/server/reviews-db.test.node.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,16 +139,22 @@ runTest('truncates long comments', () => {
139139
assert.equal(reviews[0]!.comment!.length, 2000)
140140
})
141141

142-
runTest('duplicate review from same IP is rejected', () => {
142+
runTest('duplicate review from same reviewer token is rejected', () => {
143143
const shader = `${testShader}-dupe`
144-
const ip = '192.168.1.100'
145-
addReview(shader, 4, null, 'web', null, null, ip)
144+
addReview(shader, 4, null, 'web', null, null, '192.168.1.100', 'reviewer-a')
146145
assert.throws(
147-
() => addReview(shader, 5, null, 'web', null, null, ip),
146+
() => addReview(shader, 5, null, 'web', null, null, '198.51.100.2', 'reviewer-a'),
148147
/already reviewed this shader/,
149148
)
150149
})
151150

151+
runTest('allows same shader from different reviewer tokens on same IP', () => {
152+
const shader = `${testShader}-shared-ip`
153+
const ip = '10.0.0.1'
154+
addReview(shader, 4, null, 'web', null, null, ip, 'reviewer-a')
155+
addReview(shader, 5, null, 'web', null, null, ip, 'reviewer-b')
156+
})
157+
152158
runTest('allows different shaders from same IP', () => {
153159
const ip = '10.0.0.1'
154160
addReview(`${testShader}-diff1`, 4, null, 'web', null, null, ip)
@@ -166,10 +172,10 @@ runTest('reviews without IP bypass rate limiting', () => {
166172
runTest('rate limit rejects 6th review from same IP within window', () => {
167173
const ip = '172.16.0.1'
168174
for (let i = 1; i <= 5; i++) {
169-
addReview(`${testShader}-rl-${i}`, 3, null, 'web', null, null, ip)
175+
addReview(`${testShader}-rl-${i}`, 3, null, 'web', null, null, ip, `reviewer-${i}`)
170176
}
171177
assert.throws(
172-
() => addReview(`${testShader}-rl-6`, 3, null, 'web', null, null, ip),
178+
() => addReview(`${testShader}-rl-6`, 3, null, 'web', null, null, ip, 'reviewer-6'),
173179
/Too many reviews submitted/,
174180
)
175181
})

apps/web/src/lib/server/reviews-db.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ db.exec(`
1818
agent_context TEXT,
1919
user_id TEXT,
2020
client_ip TEXT,
21+
reviewer_token_hash TEXT,
2122
created_at TEXT NOT NULL DEFAULT (datetime('now'))
2223
);
2324
CREATE INDEX IF NOT EXISTS idx_reviews_shader ON reviews(shader_name);
@@ -30,7 +31,17 @@ try {
3031
// Column already exists
3132
}
3233

34+
try {
35+
db.exec(`ALTER TABLE reviews ADD COLUMN reviewer_token_hash TEXT`)
36+
} catch {
37+
// Column already exists
38+
}
39+
3340
db.exec(`CREATE INDEX IF NOT EXISTS idx_reviews_ip_time ON reviews(client_ip, created_at)`)
41+
db.exec(
42+
`CREATE INDEX IF NOT EXISTS idx_reviews_reviewer_shader_time
43+
ON reviews(reviewer_token_hash, shader_name, created_at)`,
44+
)
3445

3546
const MAX_COMMENT_LENGTH = 2000
3647
const RATE_LIMIT_WINDOW_MINUTES = 10
@@ -60,6 +71,7 @@ export function addReview(
6071
agentContext?: string | null,
6172
userId?: string | null,
6273
clientIp?: string | null,
74+
reviewerTokenHash?: string | null,
6375
): number {
6476
// Validate rating is an integer 1-5
6577
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
@@ -86,23 +98,34 @@ export function addReview(
8698
throw new Error('Too many reviews submitted. Please try again later.')
8799
}
88100

89-
// Duplicate prevention: one review per IP per shader per 24 hours
90-
// Uses a time window instead of permanent to avoid blocking users on shared IPs (NAT, offices, mobile carriers)
101+
}
102+
103+
if (reviewerTokenHash) {
91104
const duplicate = db
92105
.prepare(
93106
`SELECT id FROM reviews
94-
WHERE client_ip = ? AND shader_name = ? AND created_at > datetime('now', '-24 hours')`,
107+
WHERE reviewer_token_hash = ?
108+
AND shader_name = ?
109+
AND created_at > datetime('now', '-24 hours')`,
95110
)
96-
.get(clientIp, shaderName) as { id: number } | undefined
111+
.get(reviewerTokenHash, shaderName) as { id: number } | undefined
97112

98113
if (duplicate) {
99114
throw new Error('You already reviewed this shader recently')
100115
}
101116
}
102117

103118
const stmt = db.prepare(
104-
`INSERT INTO reviews (shader_name, rating, comment, source, agent_context, user_id, client_ip)
105-
VALUES (?, ?, ?, ?, ?, ?, ?)`,
119+
`INSERT INTO reviews (
120+
shader_name,
121+
rating,
122+
comment,
123+
source,
124+
agent_context,
125+
user_id,
126+
client_ip,
127+
reviewer_token_hash
128+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
106129
)
107130
const result = stmt.run(
108131
shaderName,
@@ -112,6 +135,7 @@ export function addReview(
112135
agentContext ?? null,
113136
userId ?? null,
114137
clientIp ?? null,
138+
reviewerTokenHash ?? null,
115139
)
116140
return Number(result.lastInsertRowid)
117141
}

apps/web/src/routes/api/-reviews.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import { randomBytes, createHash } from 'node:crypto'
12
import { createServerFn } from '@tanstack/solid-start'
2-
import { getRequestIP } from '@tanstack/solid-start/server'
3+
import { getCookie, getRequestIP, getRequestProtocol, setCookie } from '@tanstack/solid-start/server'
34

45
type SubmitReviewInput = {
56
shaderName: string
@@ -14,10 +15,34 @@ type GetReviewsInput = {
1415
shaderName: string
1516
}
1617

18+
const REVIEWER_COOKIE_NAME = 'shaderbase-reviewer'
19+
const REVIEWER_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365
20+
1721
function getClientIp(): string | null {
1822
return getRequestIP({ xForwardedFor: true }) ?? null
1923
}
2024

25+
function hashReviewerToken(token: string): string {
26+
return createHash('sha256').update(token).digest('hex')
27+
}
28+
29+
function getOrCreateReviewerTokenHash(): string | null {
30+
let reviewerToken = getCookie(REVIEWER_COOKIE_NAME)
31+
32+
if (!reviewerToken) {
33+
reviewerToken = randomBytes(32).toString('hex')
34+
setCookie(REVIEWER_COOKIE_NAME, reviewerToken, {
35+
httpOnly: true,
36+
maxAge: REVIEWER_COOKIE_MAX_AGE_SECONDS,
37+
path: '/',
38+
sameSite: 'lax',
39+
secure: getRequestProtocol({ xForwardedProto: true }) === 'https',
40+
})
41+
}
42+
43+
return reviewerToken ? hashReviewerToken(reviewerToken) : null
44+
}
45+
2146
export const submitReview = createServerFn({ method: 'POST' })
2247
.inputValidator((input: SubmitReviewInput) => input)
2348
.handler(async ({ data }) => {
@@ -31,6 +56,7 @@ export const submitReview = createServerFn({ method: 'POST' })
3156
}
3257

3358
const clientIp = getClientIp()
59+
const reviewerTokenHash = getOrCreateReviewerTokenHash()
3460

3561
const reviewId = addReview(
3662
data.shaderName,
@@ -40,6 +66,7 @@ export const submitReview = createServerFn({ method: 'POST' })
4066
data.agentContext ? JSON.stringify(data.agentContext) : null,
4167
data.userId ?? null,
4268
clientIp,
69+
reviewerTokenHash,
4370
)
4471

4572
return { ok: true as const, reviewId }

0 commit comments

Comments
 (0)