Skip to content

Commit 9b5fd47

Browse files
committed
fix(visitors): add unique-per-IP daily deduplication to visitor counter (#2)
- Add visitor_logs table (username, ip_hash, visit_date) with a unique index on (username, ip_hash, visit_date) so the same client IP is never counted more than once per calendar day. - Hash the client IP with truncated SHA-256 for privacy. - INSERT ... ON CONFLICT DO NOTHING is the single atomic write; an empty RETURNING array signals an already-counted visit (no increment). - Add Drizzle migration 0001_visitor_logs.sql and update the journal. Closes #2
1 parent 279149e commit 9b5fd47

4 files changed

Lines changed: 85 additions & 11 deletions

File tree

drizzle/0001_visitor_logs.sql

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
CREATE TABLE IF NOT EXISTS `visitor_logs` (
2+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
3+
`username` text NOT NULL,
4+
`ip_hash` text NOT NULL,
5+
`visit_date` text NOT NULL,
6+
`created_at` integer
7+
);
8+
--> statement-breakpoint
9+
CREATE UNIQUE INDEX IF NOT EXISTS `uq_visitor_log` ON `visitor_logs` (`username`,`ip_hash`,`visit_date`);

drizzle/meta/_journal.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
88
"when": 1771919072157,
99
"tag": "0000_supreme_bulldozer",
1010
"breakpoints": true
11+
},
12+
{
13+
"idx": 1,
14+
"version": "6",
15+
"when": 1740369600000,
16+
"tag": "0001_visitor_logs",
17+
"breakpoints": true
1118
}
1219
]
1320
}

src/controllers/badge.ts

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import crypto from 'node:crypto';
12
import { Request, Response } from 'express';
23
import { db } from '../db/index.js';
3-
import { badges } from '../db/schema.js';
4+
import { badges, visitorLogs } from '../db/schema.js';
45
import { sql, eq } from 'drizzle-orm';
56
import { GitHubClient } from '../utils/github-client.js';
67
import { BadgeRenderer } from '../components/badge-renderer.js';
@@ -157,22 +158,61 @@ export class BadgeController {
157158
return res.send(svg);
158159
}
159160

160-
/** GET /badge/visitors — increments visit counter, never cached */
161+
/** GET /badge/visitors — counts unique visitors per IP per calendar day. */
161162
static async getVisitors(req: Request, res: Response) {
162163
try {
163164
const username = BadgeController.requireUsername(req, res);
164165
if (!username) return;
165166

166-
const result = await db
167-
.insert(badges)
168-
.values({ username, visitors: 1 })
169-
.onConflictDoUpdate({
170-
target: badges.username,
171-
set: { visitors: sql`${badges.visitors} + 1` },
172-
})
167+
// Resolve the real client IP (works behind reverse proxies)
168+
const rawIp = (
169+
(req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0].trim() ||
170+
req.socket?.remoteAddress ||
171+
'unknown'
172+
);
173+
174+
// Hash the IP for privacy — truncated SHA-256 is enough for dedup
175+
const ipHash = crypto
176+
.createHash('sha256')
177+
.update(rawIp)
178+
.digest('hex')
179+
.slice(0, 16);
180+
181+
// Calendar date in UTC (YYYY-MM-DD)
182+
const visitDate = new Date().toISOString().split('T')[0];
183+
184+
// Attempt to record this unique IP+date combination.
185+
// If the row already exists the insert is a no-op (ON CONFLICT DO NOTHING)
186+
// and `.returning()` returns an empty array — meaning we don't double-count.
187+
const logInsert = await db
188+
.insert(visitorLogs)
189+
.values({ username, ip_hash: ipHash, visit_date: visitDate, created_at: Date.now() })
190+
.onConflictDoNothing()
173191
.returning();
174192

175-
const count = result[0]?.visitors ?? 1;
193+
let count: number;
194+
195+
if (logInsert.length > 0) {
196+
// New unique visit — atomically increment the stored total
197+
const result = await db
198+
.insert(badges)
199+
.values({ username, visitors: 1 })
200+
.onConflictDoUpdate({
201+
target: badges.username,
202+
set: { visitors: sql`${badges.visitors} + 1` },
203+
})
204+
.returning();
205+
count = result[0]?.visitors ?? 1;
206+
} else {
207+
// Same IP already counted today — serve the current total without mutating
208+
const badge = await db
209+
.select({ visitors: badges.visitors })
210+
.from(badges)
211+
.where(eq(badges.username, username))
212+
.get();
213+
count = badge?.visitors ?? 0;
214+
}
215+
176216
const options = BadgeController.parseOptions(req, 'visitors');
177217
const svg = BadgeRenderer.generateBadge(count, options);
178218

src/db/schema.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,22 @@
1-
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
1+
import { sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core";
2+
3+
export const visitorLogs = sqliteTable(
4+
"visitor_logs",
5+
{
6+
id: integer("id").primaryKey({ autoIncrement: true }),
7+
username: text("username").notNull(),
8+
ip_hash: text("ip_hash").notNull(),
9+
visit_date: text("visit_date").notNull(), // YYYY-MM-DD
10+
created_at: integer("created_at"),
11+
},
12+
(table) => ({
13+
uqVisitorLog: uniqueIndex("uq_visitor_log").on(
14+
table.username,
15+
table.ip_hash,
16+
table.visit_date,
17+
),
18+
}),
19+
);
220

321
export const badges = sqliteTable("badges", {
422
username: text("username").primaryKey(),

0 commit comments

Comments
 (0)