diff --git a/packages/api/src/features/resume/service.ts b/packages/api/src/features/resume/service.ts index 4e00f749d..909b13259 100644 --- a/packages/api/src/features/resume/service.ts +++ b/packages/api/src/features/resume/service.ts @@ -17,6 +17,7 @@ import { getStorageService } from "../storage/service"; import { grantResumeAccess, hasResumeAccess } from "./access"; import { assertCanView, isOwner, redactResumeForViewer, shouldCountForStatistics } from "./access-policy"; import { publishResumeUpdated } from "./events"; +import { clientKeyFromHeaders, shouldCountView } from "./view-dedup"; type DbOrTx = typeof db | Parameters[0]>[0]; @@ -503,7 +504,10 @@ export const resumeService = { } if (shouldCountForStatistics(resume, viewer)) { - await resumeService.statistics.increment({ id: resume.id, views: true }); + const key = `${resume.id}:${clientKeyFromHeaders(input.requestHeaders)}`; + if (shouldCountView(key, Date.now())) { + await resumeService.statistics.increment({ id: resume.id, views: true }); + } } return toSharedResumeResponse(redactResumeForViewer(resume, isOwner(resume, viewer)), resume.hasPassword); diff --git a/packages/api/src/features/resume/view-dedup.test.ts b/packages/api/src/features/resume/view-dedup.test.ts new file mode 100644 index 000000000..a823689c7 --- /dev/null +++ b/packages/api/src/features/resume/view-dedup.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { clientKeyFromHeaders, shouldCountView } from "./view-dedup"; + +// `seen` is module-level state shared across tests, so each test uses a unique key. +const WINDOW_MS = 60 * 60 * 1000; + +describe("shouldCountView", () => { + it("counts the first view, skips repeats within the window, counts again after it", () => { + const key = "resume-1:viewer-a"; + const t0 = 1_000_000; + + expect(shouldCountView(key, t0)).toBe(true); + expect(shouldCountView(key, t0 + 1)).toBe(false); + expect(shouldCountView(key, t0 + WINDOW_MS - 1)).toBe(false); + // Once now is past the window, the same key counts again. + expect(shouldCountView(key, t0 + WINDOW_MS + 1)).toBe(true); + }); + + it("treats different keys independently", () => { + const t0 = 2_000_000; + + expect(shouldCountView("resume-2:viewer-a", t0)).toBe(true); + expect(shouldCountView("resume-2:viewer-b", t0)).toBe(true); + expect(shouldCountView("resume-2:viewer-a", t0 + 1)).toBe(false); + }); +}); + +describe("clientKeyFromHeaders", () => { + it("derives distinct keys from distinct trusted-IP headers", () => { + const a = clientKeyFromHeaders(new Headers({ "X-Forwarded-For": "1.1.1.1" })); + const b = clientKeyFromHeaders(new Headers({ "X-Forwarded-For": "2.2.2.2" })); + + expect(a).toBe("ip:1.1.1.1"); + expect(a).not.toBe(b); + }); + + it("uses the first IP from a comma-delimited proxy chain", () => { + const key = clientKeyFromHeaders(new Headers({ "X-Forwarded-For": "3.3.3.3, 10.0.0.1" })); + expect(key).toBe("ip:3.3.3.3"); + }); + + it("falls back to a stable user-agent fingerprint when no trusted IP header is present", () => { + const headers = new Headers({ "user-agent": "Mozilla/5.0", "accept-language": "en-US,en" }); + + const first = clientKeyFromHeaders(headers); + const second = clientKeyFromHeaders(headers); + + expect(first).toBe(second); + expect(first.startsWith("fp:")).toBe(true); + // A different UA yields a different fallback key. + expect(clientKeyFromHeaders(new Headers({ "user-agent": "curl/8" }))).not.toBe(first); + }); +}); diff --git a/packages/api/src/features/resume/view-dedup.ts b/packages/api/src/features/resume/view-dedup.ts new file mode 100644 index 000000000..a699931a7 --- /dev/null +++ b/packages/api/src/features/resume/view-dedup.ts @@ -0,0 +1,45 @@ +import { TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit"; + +// ponytail: in-memory per-process dedup window. Single-instance is the default deploy; for +// multi-instance, swap the Map for a Redis SETNX+EXPIRE keyed the same way (REDIS_URL already +// exists in env). Upgrade only if you scale out — each instance dedups independently otherwise. +const WINDOW_MS = 60 * 60 * 1000; // 1 hour +const MAX_ENTRIES = 50_000; // bound the Map; prune expired entries before growing past this. + +const seen = new Map(); // key -> expiry timestamp + +/** + * Returns `true` at most once per `key` per window. `now` is a parameter (not `Date.now()`) + * so callers stay testable with a driven clock. + */ +export function shouldCountView(key: string, now: number): boolean { + const expiry = seen.get(key); + if (expiry !== undefined && expiry > now) return false; + + if (seen.size >= MAX_ENTRIES) { + for (const [k, exp] of seen) { + if (exp <= now) seen.delete(k); + } + } + + seen.set(key, now + WINDOW_MS); + return true; +} + +// Mirrors the rate-limit middleware's client-key derivation so dedup and rate limiting agree on +// "who is this viewer": trusted proxy IP first, then a user-agent + language fingerprint fallback. +export function clientKeyFromHeaders(headers: Headers): string { + for (const headerName of TRUSTED_IP_HEADERS) { + const raw = headers.get(headerName)?.trim(); + if (!raw) continue; + + // Some proxies provide a comma-delimited chain; the first item is the original client. + const ip = raw.split(",")[0]?.trim(); + if (ip) return `ip:${ip}`; + } + + const userAgent = headers.get("user-agent")?.trim() ?? "unknown"; + const language = headers.get("accept-language")?.split(",")[0]?.trim() ?? "none"; + + return `fp:${userAgent.slice(0, 64)}:${language.slice(0, 16)}`; +}