Skip to content

Count /papers visits by client cookie, not shared LAN IP - #20

Draft
znsoftm wants to merge 1 commit into
cursor/papers-google-translate-f7aefrom
cursor/papers-visit-client-id-2cbc
Draft

znsoftm wants to merge 1 commit into
cursor/papers-google-translate-f7aefrom
cursor/papers-visit-client-id-2cbc

Conversation

@znsoftm

@znsoftm znsoftm commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

Summary

The catalog visit counter no longer treats every HTML refresh as a new visit, and it does not key uniqueness by client IP (which would collapse everyone behind one office NAT / X-Forwarded-For).

Current code on this line was incrementing on every GET /papers with no visitor key (IP was never used). The bug to fix was refresh/bot spam plus the wrong fix of keying by IP. Unique identity is now a first-party cookie.

Counting rules

  • What increments: GET /papers HTML only (the catalog page). HEAD, /papers/api polls, and PDF downloads do not.
  • Unique key: first-party cookie rs_papers_visitor (HttpOnly, Path=/, SameSite=Lax, 1 year; Secure on HTTPS / X-Forwarded-Proto). Minted on first catalog open.
  • Dedupe window: same cookie increments at most once per 12 hours (fixed from last counted visit, so a tab refresh does not slide the window forever).
  • Same LAN / public IP, two browsers or devices: two cookies → two counts.
  • Same browser refresh inside the window: no increment.
  • Return after 12h: counts again.
  • Cookie-less clients: each request looks like a new visitor (cookie is set on the response; browsers that keep it are deduped next time). Documented tradeoff vs merging a whole NAT.
  • Bots: empty UA, known crawler/CLI UA substrings, and prefetch/preview (Purpose / Sec-Purpose / X-Moz) are skipped.
  • IP for ops only: X-Real-IP (preferred; nginx overwrites it), else rightmost X-Forwarded-For hop (nginx appends $remote_addr), else RemoteAddr. Leftmost XFF is ignored so clients cannot spoof the logged IP. Stored as last_ip on visit-stats.json and printed on increment; never used as the visitor key.
  • Persistence: $PAPERS_DIR/visit-stats.json (already gitignored) keeps total plus recent cookie→timestamp entries, pruned after the window. Legacy {total} files still load.

GET /papers/api now sends Cache-Control: no-store so the UI does not freeze a stale count. papers() init is mutexed so concurrent first hits share one counter.

Stacked on cursor/papers-google-translate-f7ae.

Test plan

  • go test ./internal/api/ -count=1 -run 'Visit|RequestClientIP|IsBotVisit|PapersPageVisit'
  • Same IP + two new cookies increments twice; same cookie refresh does not
  • X-Real-IP / rightmost X-Forwarded-For recorded; spoofed leftmost XFF is not
  • Googlebot / curl / empty UA / prefetch do not increment
  • Window expiry (injected clock) counts the same cookie again after 12h
  • HTTP: /papers sets cookie; /papers/api exposes visits with no-store; HEAD does not bump
  • go test ./internal/api/ -count=1 (one earlier ./... run hit a pre-existing TestHFDailyReuseTranslateAndReviewRoutes TempDir cleanup flake; isolated re-run and full internal/api pass)
  • Live HTTP: Chrome + Firefox behind the same X-Real-IP / shared XFF → 2; refresh and curl/HEAD/API → still 2; last_ip is the real client, not spoofed leftmost XFF
  • Browser: /papers shows 3 visits (after those two clients + this browser); refresh stays 3; /papers/api "visits": 3
Open in Web Open in Cursor 

Dedupe catalog HTML opens per rs_papers_visitor cookie (12h window)
so LAN users behind one public IP stay distinct, refreshes do not
spam-increment, and X-Real-IP / X-Forwarded-For are recorded for ops
only. Skip obvious bots and prefetch; persist seen ids in visit-stats.json.

Co-authored-by: Daniel <znsoft@163.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants