Changeset e3a5aed in Klonkt


Ignore:
Timestamp:
06/30/2026 10:11:03 PM (2 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
d9c5eb8
Parents:
65a0697
Message:

feat(stats): let the admin exclude their own IP from statistics

Logged-in admins were already skipped, but an admin browsing logged out (incognito, another browser)
still inflated the numbers. Admin -> Statistics now shows your current IP with a one-click "Don't
count my visits" toggle, stored in a stats_exclude_ips app_setting and checked on every pageview
(recordPageview/recordPostView). Trust-proxy gives the real client IP; normalised for matching.

Co-Authored-By: Claude <noreply@…>

Location:
src
Files:
4 edited

Legend:

Unmodified
Added
Removed
  • src/routes/admin-stats.js

    r65a0697 re3a5aed  
    1414import { requireGod } from '../middleware/auth.js';
    1515import { premiumUnlocked } from '../services/PatreonService.js';
    16 import { getStats } from '../services/StatsService.js';
     16import { getStats, currentIp, getExcludedIps, setExcludedIps } from '../services/StatsService.js';
    1717
    1818const router = express.Router();
     
    3232  }
    3333  const days = [7, 14, 30, 90].includes(parseInt(req.query.days, 10)) ? parseInt(req.query.days, 10) : 14;
     34  const myIp = currentIp(req);
    3435  renderPage(req, res, 'pages/admin-stats', {
    3536    pageTitleKey: 'admin.t_stats',
     
    3738    stats: getStats(days),
    3839    linkClicks,
     40    myIp,
     41    ipExcluded: !!myIp && getExcludedIps().includes(myIp),
    3942  });
    4043});
    4144
     45// Toggle whether the admin's current IP is counted in statistics.
     46router.post('/exclude-ip', requireGod, (req, res) => {
     47  const ip = currentIp(req);
     48  if (ip) {
     49    const list = getExcludedIps();
     50    const i = list.indexOf(ip);
     51    if (i >= 0) list.splice(i, 1); else list.push(ip);
     52    setExcludedIps(list);
     53  }
     54  res.redirect('/admin/stats');
     55});
     56
    4257export default router;
  • src/services/StatsService.js

    r65a0697 re3a5aed  
    5454}
    5555
     56// The client IP (trust-proxy gives the real one), normalised: drop an IPv4-mapped-IPv6 prefix
     57// and a trailing :port so it matches what the admin sees + stores.
     58function clientIp(req) {
     59  let ip = (req && (req.ip || (req.socket && req.socket.remoteAddress))) || '';
     60  if (ip.startsWith('::ffff:')) ip = ip.slice(7);
     61  if (/^\d{1,3}(\.\d{1,3}){3}:\d+$/.test(ip)) ip = ip.split(':')[0];
     62  return ip;
     63}
     64export function currentIp(req) { return clientIp(req); }
     65
     66// Admin-configured IPs to skip — so an owner browsing logged-OUT (incognito, another browser)
     67// doesn't inflate their own stats. Stored as a comma-separated app_setting.
     68export function getExcludedIps() {
     69  return (getSetting('stats_exclude_ips', '') || '').split(',').map((s) => s.trim()).filter(Boolean);
     70}
     71export function setExcludedIps(list) {
     72  const clean = [...new Set((list || []).map((s) => String(s).trim()).filter(Boolean))].slice(0, 20);
     73  setSetting('stats_exclude_ips', clean.join(','));
     74}
     75function isExcludedIp(req) {
     76  try { const ip = clientIp(req); return !!ip && getExcludedIps().includes(ip); } catch { return false; }
     77}
     78
    5679// Lazy prepares — tables only exist after initializeDatabase(); this module is
    5780// imported before that call.
     
    90113
    91114export function recordPageview(siteId, req) {
    92   if (!siteId || isOperator(req) || isBot(req)) return;
     115  if (!siteId || isOperator(req) || isBot(req) || isExcludedIp(req)) return;
    93116  try {
    94117    const d = today();
     
    100123
    101124export function recordPostView(post, req) {
    102   if (!post || !post.id || isOperator(req) || isBot(req)) return;
     125  if (!post || !post.id || isOperator(req) || isBot(req) || isExcludedIp(req)) return;
    103126  try {
    104127    stmts().bumpPost.run(post.id);
  • src/services/i18n.js

    r65a0697 re3a5aed  
    356356    'astat.title': 'Statistieken',
    357357    'astat.intro': 'Cookievrij gemeten — geen tracking-cookies, geen toestemmingsbanner. Bezoekers worden per dag geteld via een dagelijks roterende, anonieme hash (IP/browser worden niet bewaard). Je eigen beheerder-bezoeken en bekende bots/crawlers tellen niet mee.',
     358    'astat.your_ip': 'Jouw IP', 'astat.ip_counted': 'wordt meegeteld.', 'astat.ip_not_counted': 'wordt NIET meegeteld.', 'astat.ip_exclude': 'Tel mijn bezoeken niet mee', 'astat.ip_count': 'Wel meetellen',
    358359    'astat.visitor_days': 'Bezoeker-dagen ({n}d)',
    359360    'astat.pageviews_days': 'Weergaven ({n}d)',
     
    12741275    'astat.title': 'Statistics',
    12751276    'astat.intro': 'Measured cookie-free — no tracking cookies, no consent banner. Visitors are counted per day via a daily-rotating, anonymous hash (IP/browser are not stored). Your own admin visits and known bots/crawlers are not counted.',
     1277    'astat.your_ip': 'Your IP', 'astat.ip_counted': 'is being counted.', 'astat.ip_not_counted': 'is NOT counted.', 'astat.ip_exclude': "Don't count my visits", 'astat.ip_count': 'Count my visits',
    12761278    'astat.visitor_days': 'Visitor-days ({n}d)',
    12771279    'astat.pageviews_days': 'Views ({n}d)',
     
    21912193    'astat.title': 'Statistiken',
    21922194    'astat.intro': 'Cookiefrei gemessen — keine Tracking-Cookies, kein Zustimmungsbanner. Besucher werden pro Tag über einen täglich rotierenden, anonymen Hash gezählt (IP/Browser werden nicht gespeichert). Deine eigenen Admin-Besuche und bekannte Bots/Crawler werden nicht mitgezählt.',
     2195    'astat.your_ip': 'Deine IP', 'astat.ip_counted': 'wird mitgezählt.', 'astat.ip_not_counted': 'wird NICHT mitgezählt.', 'astat.ip_exclude': 'Meine Besuche nicht zählen', 'astat.ip_count': 'Meine Besuche zählen',
    21932196    'astat.visitor_days': 'Besuchertage ({n}T)',
    21942197    'astat.pageviews_days': 'Aufrufe ({n}T)',
  • src/views/pages/admin-stats.ejs

    r65a0697 re3a5aed  
    33  <h1><%= t('astat.title') %></h1>
    44  <p class="stats-note"><%= t('astat.intro') %></p>
     5
     6  <% if (typeof myIp !== 'undefined' && myIp) { %>
     7  <form method="post" action="/admin/stats/exclude-ip" class="stats-note" style="margin:.2rem 0 1.2rem;display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;">
     8    <span><%= t('astat.your_ip') %> <code><%= myIp %></code> —
     9      <% if (ipExcluded) { %><strong><%= t('astat.ip_not_counted') %></strong><% } else { %><%= t('astat.ip_counted') %><% } %></span>
     10    <button type="submit" class="btn" style="padding:3px 10px;font-size:.85rem;"><%= ipExcluded ? t('astat.ip_count') : t('astat.ip_exclude') %></button>
     11  </form>
     12  <% } %>
    513
    614  <% var s = stats; var _max = Math.max(1, ...s.series.map(function(d){ return d.pageviews; })); %>
Note: See TracChangeset for help on using the changeset viewer.