|
3 | 3 | * Handles user-specific badge endpoints (require username parameter) |
4 | 4 | * Features: Redis persistent caching with intelligent TTL, request deduplication |
5 | 5 | */ |
6 | | -import crypto from 'node:crypto'; |
7 | 6 | import { Request, Response } from 'express'; |
8 | 7 | import { db } from '../db/index.js'; |
9 | | -import { badges, visitorLogs } from '../db/schema.js'; |
| 8 | +import { badges } from '../db/schema.js'; |
10 | 9 | import { sql, eq } from 'drizzle-orm'; |
11 | 10 | import { GitHubClient } from '../utils/github-client.js'; |
12 | 11 | import { BadgeRenderer } from '../components/badge-renderer.js'; |
@@ -284,120 +283,29 @@ export class UserBadgeController { |
284 | 283 | // ═══════════════════════════════════════════════════════════════════════ |
285 | 284 | // Badge Endpoints |
286 | 285 | // ═══════════════════════════════════════════════════════════════════════ |
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. */ |
302 | 287 | static async getVisitors(req: Request, res: Response) { |
303 | 288 | try { |
304 | 289 | const username = UserBadgeController.requireUsername(req, res); |
305 | 290 | if (!username) return; |
306 | 291 |
|
307 | 292 | 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 | + }) |
355 | 300 | .returning(); |
356 | 301 |
|
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; |
382 | 303 |
|
383 | 304 | const svg = BadgeRenderer.generateBadge(count, options); |
384 | 305 |
|
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 | | - |
398 | 306 | 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'); |
401 | 309 | return res.send(svg); |
402 | 310 | } catch (err) { |
403 | 311 | console.error('UserBadgeController.getVisitors:', err); |
|
0 commit comments