Skip to content

Commit fa0d8fe

Browse files
committed
Better duplicate detection
* We don't store real IPs anymore, only hashes * An IP for a URL can not clap multiple times in 1 day.
1 parent 452122f commit fa0d8fe

File tree

2 files changed

+51
-19
lines changed

2 files changed

+51
-19
lines changed

src/services/clapService.test.ts

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
22

33
const mockRedis = vi.hoisted(() => ({
44
hget: vi.fn(),
5-
hset: vi.fn(),
65
hincrby: vi.fn(),
6+
sismember: vi.fn(),
7+
sadd: vi.fn(),
8+
expire: vi.fn(),
79
pipeline: vi.fn(),
810
}));
911

@@ -32,54 +34,70 @@ describe("updateClaps", () => {
3234
beforeEach(() => vi.clearAllMocks());
3335

3436
it("increments claps for new IP", async () => {
35-
mockRedis.hget.mockResolvedValue(null);
37+
mockRedis.sismember.mockResolvedValue(0);
3638
mockRedis.hincrby.mockResolvedValue(5);
37-
mockRedis.hset.mockResolvedValue("OK");
39+
mockRedis.sadd.mockResolvedValue(1);
40+
mockRedis.expire.mockResolvedValue(1);
3841

3942
expect(await updateClaps("foo.com", 5, "1.2.3.4")).toBe(5);
4043
expect(mockRedis.hincrby).toHaveBeenCalledWith("applause:foo.com", "claps", 5);
41-
expect(mockRedis.hset).toHaveBeenCalledWith("applause:foo.com", "lastIp", "1.2.3.4");
44+
expect(mockRedis.sadd).toHaveBeenCalledWith("applause:ips:foo.com", expect.any(String));
45+
expect(mockRedis.expire).toHaveBeenCalledWith("applause:ips:foo.com", 86400);
46+
});
47+
48+
it("stores hashed IP, not raw IP", async () => {
49+
mockRedis.sismember.mockResolvedValue(0);
50+
mockRedis.hincrby.mockResolvedValue(5);
51+
mockRedis.sadd.mockResolvedValue(1);
52+
mockRedis.expire.mockResolvedValue(1);
53+
54+
await updateClaps("foo.com", 5, "1.2.3.4");
55+
const storedHash = mockRedis.sadd.mock.calls[0][1];
56+
expect(storedHash).not.toBe("1.2.3.4");
57+
expect(storedHash).toMatch(/^[a-f0-9]{64}$/);
4258
});
4359

4460
it("blocks duplicate claps from same IP", async () => {
45-
mockRedis.hget
46-
.mockResolvedValueOnce("1.2.3.4") // lastIp
47-
.mockResolvedValueOnce("10"); // current claps
61+
mockRedis.sismember.mockResolvedValue(1);
62+
mockRedis.hget.mockResolvedValue("10");
4863

4964
expect(await updateClaps("foo.com", 5, "1.2.3.4")).toBe(10);
5065
expect(mockRedis.hincrby).not.toHaveBeenCalled();
66+
expect(mockRedis.sadd).not.toHaveBeenCalled();
5167
});
5268

5369
it("returns 0 when same IP and no claps stored", async () => {
54-
mockRedis.hget
55-
.mockResolvedValueOnce("1.2.3.4") // lastIp
56-
.mockResolvedValueOnce(null); // no claps
70+
mockRedis.sismember.mockResolvedValue(1);
71+
mockRedis.hget.mockResolvedValue(null);
5772

5873
expect(await updateClaps("foo.com", 5, "1.2.3.4")).toBe(0);
5974
});
6075

6176
it("allows claps from different IP", async () => {
62-
mockRedis.hget.mockResolvedValueOnce("1.2.3.4");
77+
mockRedis.sismember.mockResolvedValue(0);
6378
mockRedis.hincrby.mockResolvedValue(15);
64-
mockRedis.hset.mockResolvedValue("OK");
79+
mockRedis.sadd.mockResolvedValue(1);
80+
mockRedis.expire.mockResolvedValue(1);
6581

6682
expect(await updateClaps("foo.com", 5, "5.6.7.8")).toBe(15);
6783
expect(mockRedis.hincrby).toHaveBeenCalled();
6884
});
6985

7086
it("clamps clap increment to max 10", async () => {
71-
mockRedis.hget.mockResolvedValue(null);
87+
mockRedis.sismember.mockResolvedValue(0);
7288
mockRedis.hincrby.mockResolvedValue(10);
73-
mockRedis.hset.mockResolvedValue("OK");
89+
mockRedis.sadd.mockResolvedValue(1);
90+
mockRedis.expire.mockResolvedValue(1);
7491

7592
await updateClaps("foo.com", 100, "1.2.3.4");
7693
expect(mockRedis.hincrby).toHaveBeenCalledWith("applause:foo.com", "claps", 10);
7794
});
7895

7996
it("clamps negative values to 1", async () => {
80-
mockRedis.hget.mockResolvedValue(null);
97+
mockRedis.sismember.mockResolvedValue(0);
8198
mockRedis.hincrby.mockResolvedValue(1);
82-
mockRedis.hset.mockResolvedValue("OK");
99+
mockRedis.sadd.mockResolvedValue(1);
100+
mockRedis.expire.mockResolvedValue(1);
83101

84102
await updateClaps("foo.com", -5, "1.2.3.4");
85103
expect(mockRedis.hincrby).toHaveBeenCalledWith("applause:foo.com", "claps", 1);

src/services/clapService.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
1+
import { hash } from "node:crypto";
12
import { redis } from "../lib/redis.js";
23
import { clamp, normalizeUrl, unique } from "../lib/util.js";
34

45
const KEY_PREFIX = "applause:";
56

7+
const IP_TTL_SECONDS = 86400; // 1 day
8+
69
function key(url: string): string {
710
return `${KEY_PREFIX}${url}`;
811
}
912

13+
function ipSetKey(url: string): string {
14+
return `${KEY_PREFIX}ips:${url}`;
15+
}
16+
17+
function hashIp(ip: string): string {
18+
return hash("sha256", ip, "hex");
19+
}
20+
1021
export async function getClaps(url: string): Promise<number> {
1122
const claps = await redis.hget(key(url), "claps");
1223
return claps ? parseInt(claps, 10) : 0;
@@ -18,17 +29,20 @@ export async function updateClaps(
1829
sourceIp: string,
1930
): Promise<number> {
2031
const k = key(url);
32+
const ik = ipSetKey(url);
2133
const clapIncrement = clamp(clapCount, 1, 10);
34+
const hashedIp = hashIp(sourceIp);
2235

23-
const lastIp = await redis.hget(k, "lastIp");
36+
const alreadyClapped = await redis.sismember(ik, hashedIp);
2437

25-
if (lastIp && lastIp === sourceIp) {
38+
if (alreadyClapped) {
2639
const current = await redis.hget(k, "claps");
2740
return current ? parseInt(current, 10) : 0;
2841
}
2942

3043
const newTotal = await redis.hincrby(k, "claps", clapIncrement);
31-
await redis.hset(k, "lastIp", sourceIp);
44+
await redis.sadd(ik, hashedIp);
45+
await redis.expire(ik, IP_TTL_SECONDS);
3246

3347
return newTotal;
3448
}

0 commit comments

Comments
 (0)