Skip to content

Commit 1376ee0

Browse files
committed
feat: update visitor tracking to increment count on each request and streamline badge retrieval
1 parent 2350fcb commit 1376ee0

3 files changed

Lines changed: 71 additions & 172 deletions

File tree

src/controllers/badge-collection.controller.ts

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { Request, Response } from 'express';
77
import { createHash } from 'crypto';
88
import { db } from '../db/index.js';
99
import { badges } from '../db/schema.js';
10-
import { eq } from 'drizzle-orm';
10+
import { eq, sql } from 'drizzle-orm';
1111
import { GitHubClient } from '../utils/github-client.js';
1212
import { BadgeRenderer } from '../components/badge-renderer.js';
1313
import { badgeThemes } from '../utils/themes.js';
@@ -158,6 +158,20 @@ export class BadgeCollectionController {
158158
};
159159
}
160160

161+
/** Increment all-time visitors and return the latest value. */
162+
private static async incrementVisitorsCount(username: string): Promise<number> {
163+
const result = await db
164+
.insert(badges)
165+
.values({ username, visitors: 1, updated_at: Date.now() })
166+
.onConflictDoUpdate({
167+
target: badges.username,
168+
set: { visitors: sql`${badges.visitors} + 1`, updated_at: Date.now() },
169+
})
170+
.returning();
171+
172+
return result[0]?.visitors ?? 1;
173+
}
174+
161175
/**
162176
* Fetch the current numeric value for a badge type.
163177
* For `visitors`, the count is read without being incremented.
@@ -393,6 +407,7 @@ export class BadgeCollectionController {
393407
}
394408

395409
const types = rawTypes as UserBadgeType[];
410+
const includesVisitors = types.includes('visitors');
396411

397412
// 3. Validate layout options
398413
const columns = BadgeCollectionController.normalizeColumns(req.query.columns);
@@ -448,27 +463,36 @@ export class BadgeCollectionController {
448463
return;
449464
}
450465

451-
// 5. Check in-memory collection cache
466+
// 5. Check in-memory collection cache (disabled for visitors to keep counter live)
452467
const cacheKey = BadgeCollectionController.generateCacheKey(username, types, sharedOptions, columns, gap, padding, themes, effect);
453-
const cached = BadgeCollectionController.svgCache.get(cacheKey);
454-
if (cached) {
455-
if (req.headers['if-none-match'] === cached.etag) {
456-
res.status(304).end();
468+
if (!includesVisitors) {
469+
const cached = BadgeCollectionController.svgCache.get(cacheKey);
470+
if (cached) {
471+
if (req.headers['if-none-match'] === cached.etag) {
472+
res.status(304).end();
473+
return;
474+
}
475+
BadgeCollectionController.setImageHeaders(res, cached.etag);
476+
res.send(cached.content);
457477
return;
458478
}
459-
BadgeCollectionController.setImageHeaders(res, cached.etag);
460-
res.send(cached.content);
461-
return;
462479
}
463480

464481
// 6. Deduplicate in-flight renders for the same cache key
465482
let pending = BadgeCollectionController.pendingCollections.get(cacheKey);
466483
if (!pending) {
467484
pending = (async () => {
485+
const visitorsValue = includesVisitors
486+
? await BadgeCollectionController.incrementVisitorsCount(username)
487+
: null;
488+
468489
// Fetch all badge values in parallel
469-
const values = await Promise.all(
470-
types.map((type) => BadgeCollectionController.fetchBadgeValue(username, type))
471-
);
490+
const values = await Promise.all(types.map((type) => {
491+
if (type === 'visitors') {
492+
return Promise.resolve(visitorsValue ?? 0);
493+
}
494+
return BadgeCollectionController.fetchBadgeValue(username, type);
495+
}));
472496

473497
// Render each badge as a standalone SVG
474498
const badgeSvgs = values.map((value, i) => {
@@ -487,15 +511,21 @@ export class BadgeCollectionController {
487511
const svgContent = await pending;
488512
const etag = BadgeCollectionController.createWeakEtag(svgContent);
489513

490-
if (req.headers['if-none-match'] === etag) {
491-
res.status(304).end();
492-
return;
493-
}
514+
if (!includesVisitors) {
515+
if (req.headers['if-none-match'] === etag) {
516+
res.status(304).end();
517+
return;
518+
}
494519

495-
BadgeCollectionController.svgCache.set(cacheKey, { content: svgContent, etag, timestamp: Date.now() });
496-
BadgeCollectionController.maybePruneCache();
520+
BadgeCollectionController.svgCache.set(cacheKey, { content: svgContent, etag, timestamp: Date.now() });
521+
BadgeCollectionController.maybePruneCache();
522+
523+
BadgeCollectionController.setImageHeaders(res, etag);
524+
} else {
525+
res.setHeader('Content-Type', 'image/svg+xml');
526+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
527+
}
497528

498-
BadgeCollectionController.setImageHeaders(res, etag);
499529
res.send(svgContent);
500530
} catch (error) {
501531
res.status(500).json({

src/controllers/badge.controller.ts

Lines changed: 10 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import crypto from 'node:crypto';
21
import { Request, Response } from 'express';
32
import { db } from '../db/index.js';
4-
import { badges, visitorLogs } from '../db/schema.js';
3+
import { badges } from '../db/schema.js';
54
import { sql, eq } from 'drizzle-orm';
65
import { GitHubClient, RepoBadgeType } from '../utils/github-client.js';
76
import { BadgeRenderer } from '../components/badge-renderer.js';
@@ -189,60 +188,22 @@ export class BadgeController {
189188
return res.send(svg);
190189
}
191190

192-
/** GET /badge/visitors — counts unique visitors per IP per calendar day. */
191+
/** GET /badge/visitors — increments total visitors on every request. */
193192
static async getVisitors(req: Request, res: Response) {
194193
try {
195194
const username = BadgeController.requireUsername(req, res);
196195
if (!username) return;
197196

198-
// Resolve the real client IP (works behind reverse proxies)
199-
const rawIp = (
200-
(req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0].trim() ||
201-
req.socket?.remoteAddress ||
202-
'unknown'
203-
);
204-
205-
// Hash the IP for privacy — truncated SHA-256 is enough for dedup
206-
const ipHash = crypto
207-
.createHash('sha256')
208-
.update(rawIp)
209-
.digest('hex')
210-
.slice(0, 16);
211-
212-
// Calendar date in UTC (YYYY-MM-DD)
213-
const visitDate = new Date().toISOString().split('T')[0];
214-
215-
// Attempt to record this unique IP+date combination.
216-
// If the row already exists the insert is a no-op (ON CONFLICT DO NOTHING)
217-
// and `.returning()` returns an empty array — meaning we don't double-count.
218-
const logInsert = await db
219-
.insert(visitorLogs)
220-
.values({ username, ip_hash: ipHash, visit_date: visitDate, created_at: Date.now() })
221-
.onConflictDoNothing()
197+
const result = await db
198+
.insert(badges)
199+
.values({ username, visitors: 1, updated_at: Date.now() })
200+
.onConflictDoUpdate({
201+
target: badges.username,
202+
set: { visitors: sql`${badges.visitors} + 1`, updated_at: Date.now() },
203+
})
222204
.returning();
223205

224-
let count: number;
225-
226-
if (logInsert.length > 0) {
227-
// New unique visit — atomically increment the stored total
228-
const result = await db
229-
.insert(badges)
230-
.values({ username, visitors: 1 })
231-
.onConflictDoUpdate({
232-
target: badges.username,
233-
set: { visitors: sql`${badges.visitors} + 1` },
234-
})
235-
.returning();
236-
count = result[0]?.visitors ?? 1;
237-
} else {
238-
// Same IP already counted today — serve the current total without mutating
239-
const badge = await db
240-
.select({ visitors: badges.visitors })
241-
.from(badges)
242-
.where(eq(badges.username, username))
243-
.get();
244-
count = badge?.visitors ?? 0;
245-
}
206+
const count = result[0]?.visitors ?? 1;
246207

247208
const options = BadgeController.parseOptions(req, 'visitors');
248209
const svg = BadgeRenderer.generateBadge(count, options);

src/controllers/user-badge.controller.ts

Lines changed: 12 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@
33
* Handles user-specific badge endpoints (require username parameter)
44
* Features: Redis persistent caching with intelligent TTL, request deduplication
55
*/
6-
import crypto from 'node:crypto';
76
import { Request, Response } from 'express';
87
import { db } from '../db/index.js';
9-
import { badges, visitorLogs } from '../db/schema.js';
8+
import { badges } from '../db/schema.js';
109
import { sql, eq } from 'drizzle-orm';
1110
import { GitHubClient } from '../utils/github-client.js';
1211
import { BadgeRenderer } from '../components/badge-renderer.js';
@@ -284,120 +283,29 @@ export class UserBadgeController {
284283
// ═══════════════════════════════════════════════════════════════════════
285284
// Badge Endpoints
286285
// ═══════════════════════════════════════════════════════════════════════
287-
/**
288-
* GET /badge/visitors — counts unique visitors per IP per calendar day.
289-
*
290-
* The same IP can increment the counter once per day:
291-
* - Day 1: IP visits → count +1
292-
* - Day 1 (later): Same IP visits again → count stays same (already counted today)
293-
* - Day 2: Same IP visits → count +1 (new day, allowed to count again)
294-
*
295-
* This is enforced by the unique index on (username, ip_hash, visit_date) in the visitor_logs table.
296-
*
297-
* Caching Strategy:
298-
* - Redis cache for rendered SVG (10 minutes)
299-
* - In-memory cache for rapid F5 spam (60 seconds)
300-
* - Short TTL ensures visitor counts update frequently
301-
*/
286+
/** GET /badge/visitors — increments all-time visitors on every request. */
302287
static async getVisitors(req: Request, res: Response) {
303288
try {
304289
const username = UserBadgeController.requireUsername(req, res);
305290
if (!username) return;
306291

307292
const options = UserBadgeController.parseOptions(req, 'visitors');
308-
const cacheKey = UserBadgeController.buildCacheKey(username, options);
309-
const badgeService = getBadgeCacheServiceSync();
310-
const optionsRecord = UserBadgeController.optionsToRecord(options);
311-
312-
// 1. Check Redis persistent cache first
313-
if (badgeService?.isReady()) {
314-
const redisCached = await badgeService.getUserBadgeSVG(username, 'visitors', optionsRecord);
315-
if (redisCached) {
316-
res.setHeader('Content-Type', 'image/svg+xml');
317-
res.setHeader('Cache-Control', 'public, max-age=60');
318-
res.setHeader('X-Cache', 'REDIS');
319-
return res.send(redisCached.svg);
320-
}
321-
}
322-
323-
// 2. Check in-memory SVG cache first — prevents unnecessary DB queries for rapid refreshes
324-
const cached = UserBadgeController.cache.get(cacheKey);
325-
if (cached && Date.now() - cached.timestamp < 60_000) { // 60-second cache
326-
res.setHeader('Content-Type', 'image/svg+xml');
327-
res.setHeader('Cache-Control', 'public, max-age=60');
328-
res.setHeader('X-Cache', 'MEMORY');
329-
return res.send(cached.data);
330-
}
331-
332-
// Resolve the real client IP (works behind reverse proxies)
333-
const rawIp = (
334-
(req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0].trim() ||
335-
req.socket?.remoteAddress ||
336-
'unknown'
337-
);
338-
339-
// Hash the IP for privacy — truncated SHA-256 is enough for dedup
340-
const ipHash = crypto
341-
.createHash('sha256')
342-
.update(rawIp)
343-
.digest('hex')
344-
.slice(0, 16);
345-
346-
// Calendar date in UTC (YYYY-MM-DD) — ensures same IP can count once per day
347-
const visitDate = new Date().toISOString().split('T')[0];
348-
349-
// Attempt to record this unique IP+date combination
350-
// If this IP already visited today, onConflictDoNothing() prevents duplicate insertion
351-
const logInsert = await db
352-
.insert(visitorLogs)
353-
.values({ username, ip_hash: ipHash, visit_date: visitDate, created_at: Date.now() })
354-
.onConflictDoNothing()
293+
const result = await db
294+
.insert(badges)
295+
.values({ username, visitors: 1, updated_at: Date.now() })
296+
.onConflictDoUpdate({
297+
target: badges.username,
298+
set: { visitors: sql`${badges.visitors} + 1`, updated_at: Date.now() },
299+
})
355300
.returning();
356301

357-
let count: number;
358-
let dbTimestamp = Date.now();
359-
360-
if (logInsert.length > 0) {
361-
// New unique visit for today — atomically increment the stored total
362-
const result = await db
363-
.insert(badges)
364-
.values({ username, visitors: 1, updated_at: Date.now() })
365-
.onConflictDoUpdate({
366-
target: badges.username,
367-
set: { visitors: sql`${badges.visitors} + 1`, updated_at: Date.now() },
368-
})
369-
.returning();
370-
count = result[0]?.visitors ?? 1;
371-
dbTimestamp = result[0]?.updated_at ?? Date.now();
372-
} else {
373-
// Same IP already counted today — return the current total without incrementing
374-
const badge = await db
375-
.select({ visitors: badges.visitors, updated_at: badges.updated_at })
376-
.from(badges)
377-
.where(eq(badges.username, username))
378-
.get();
379-
count = badge?.visitors ?? 0;
380-
dbTimestamp = badge?.updated_at ?? Date.now();
381-
}
302+
const count = result[0]?.visitors ?? 1;
382303

383304
const svg = BadgeRenderer.generateBadge(count, options);
384305

385-
// Cache the SVG in both layers
386-
UserBadgeController.cache.set(cacheKey, { data: svg, timestamp: Date.now() });
387-
388-
// Cache in Redis with short TTL for frequent updates
389-
if (badgeService?.isReady()) {
390-
await badgeService.setUserBadgeSVG(username, 'visitors', optionsRecord, {
391-
svg,
392-
value: count,
393-
timestamp: Date.now(),
394-
dbTimestamp,
395-
}, 60); // 60 seconds for visitors (shorter for freshness)
396-
}
397-
398306
res.setHeader('Content-Type', 'image/svg+xml');
399-
res.setHeader('Cache-Control', 'public, max-age=60');
400-
res.setHeader('X-Cache', 'MISS');
307+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
308+
res.setHeader('X-Cache', 'BYPASS');
401309
return res.send(svg);
402310
} catch (err) {
403311
console.error('UserBadgeController.getVisitors:', err);

0 commit comments

Comments
 (0)