mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-19 21:11:45 +10:00
perf(api): dedup public-resume view increments within a short window
This commit is contained in:
@@ -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<Parameters<typeof db.transaction>[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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, number>(); // 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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user