Changeset d549549 in Klonkt


Ignore:
Timestamp:
06/16/2026 09:41:28 AM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
029f047
Parents:
3f893bf
Message:

Statistics module (Phase 3): cookie-free tracking + premium stats page

Counters: posts.view_count, audio_tracks.play_count, stat_daily
(pageviews/day) + stat_visitor_day (unique visitors via daily rotating
salted hash of IP+UA, never stored → no cookie, no consent needed).
StatsService records pageview on home + post-view (skips admins), play on
the initial player stream fetch. /admin/stats (god-only +
premiumUnlocked-gated): cards + 14-day cookie-free bar chart
(visitors/views) + top posts/tracks. Dashboard button is premium-gated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@…>

Location:
src
Files:
3 added
5 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r3f893bf rd549549  
    7171  ensureColumn('posts', 'type',    "TEXT DEFAULT 'post'");  // post | foto | video | audio
    7272
     73  // Statistieken (premium-module) — kale tellers, cookievrij.
     74  ensureColumn('posts', 'view_count', 'INTEGER DEFAULT 0');         // weergaven per post
     75  ensureColumn('audio_tracks', 'play_count', 'INTEGER DEFAULT 0');  // plays per track
     76
    7377  // Playlists (v9 feature) — first-class entity. CREATE IF NOT EXISTS is
    7478  // idempotent so it's safe to run on every boot regardless of DB age.
     
    108112  `);
    109113  db.prepare("INSERT OR IGNORE INTO app_settings (key, value) VALUES ('tenancy', 'solo')").run();
     114
     115  // ── Statistieken (premium) — cookievrij ─────────────────────
     116  // stat_daily: per dag per site het aantal pageviews (kale teller).
     117  // stat_visitor_day: per dag per site een rij per UNIEKE bezoeker-hash
     118  //   (sha256 van IP+UA+dag-salt; de salt roteert dagelijks en wordt nooit
     119  //   bewaard → geen persistente identifier, geen cookie, geen toestemming nodig).
     120  db.exec(`
     121    CREATE TABLE IF NOT EXISTS stat_daily (
     122      site_id TEXT NOT NULL,
     123      day TEXT NOT NULL,
     124      pageviews INTEGER NOT NULL DEFAULT 0,
     125      PRIMARY KEY (site_id, day)
     126    );
     127    CREATE TABLE IF NOT EXISTS stat_visitor_day (
     128      site_id TEXT NOT NULL,
     129      day TEXT NOT NULL,
     130      visitor_hash TEXT NOT NULL,
     131      PRIMARY KEY (site_id, day, visitor_hash)
     132    );
     133    CREATE INDEX IF NOT EXISTS idx_stat_visitor_day ON stat_visitor_day(site_id, day);
     134  `);
    110135
    111136  // ── Cirkels (federatie) ─────────────────────────────────────
  • src/routes/audio.js

    r3f893bf rd549549  
    2828import path from 'path';
    2929import { fileURLToPath } from 'url';
     30import db from '../config/database.js';
     31import { recordPlay } from '../services/StatsService.js';
    3032
    3133const __dirname = path.dirname(fileURLToPath(import.meta.url));
     
    9092  const range = req.headers.range;
    9193
     94  // Statistieken: tel één play bij de initiële player-fetch (niet bij scrub/
     95  // range-continuaties; replays binnen 24u komen uit de browsercache → geen
     96  // dubbeltelling). Best-effort, mag nooit de stream breken.
     97  if (req.get('X-Audio-Player') === '1' && (!range || /^bytes=0-/.test(range))) {
     98    try {
     99      const tr = db.prepare(`
     100        SELECT t.id FROM audio_tracks t JOIN media m ON t.media_id = m.id
     101        WHERE m.storage_path = ? OR m.storage_path LIKE ? LIMIT 1
     102      `).get(filename, '%' + filename);
     103      if (tr) recordPlay(tr.id);
     104    } catch {}
     105  }
     106
    92107  // Common headers
    93108  res.setHeader('Content-Type', mime);
  • src/routes/posts.js

    r3f893bf rd549549  
    88import { requireAuth } from '../middleware/auth.js';
    99import { renderPage } from '../middleware/render.js';
     10import { recordPageview, recordPostView } from '../services/StatsService.js';
    1011import PermissionsService from '../services/PermissionsService.js';
    1112import MarkdownService from '../services/MarkdownService.js';
     
    113114  `).all(site.id);
    114115
     116  recordPageview(site.id, req);
     117
    115118  renderPage(req, res, 'pages/home', {
    116119    pinnedPosts,
     
    391394    if (!canEdit) return res.status(403).send('Not published');
    392395  }
     396
     397  // Statistieken: tel de weergave (skipt beheerders + niet-gepubliceerd-eigen-preview).
     398  if (post.status === 'published') recordPostView(post, req);
    393399
    394400  // Render content. Content is now user-authored HTML (already sanitized on
  • src/server.js

    r3f893bf rd549549  
    4949import adminUpdatesRoutes from './routes/admin-updates.js';
    5050import adminPatreonRoutes from './routes/admin-patreon.js';
     51import adminStatsRoutes from './routes/admin-stats.js';
    5152import circleRoutes from './routes/circle.js';
    5253
     
    241242app.use('/admin/updates', adminUpdatesRoutes);
    242243app.use('/admin/patreon', adminPatreonRoutes);
     244app.use('/admin/stats', adminStatsRoutes);
    243245app.use('/admin', adminRoutes);
    244246app.use('/prutter', prutterRoutes);
  • src/views/pages/admin.ejs

    r3f893bf rd549549  
    2929      <a href="/admin/settings" class="btn">⚙️ Instellingen</a>
    3030    <% } %>
     31    <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
     32      <a href="/admin/stats" class="btn">📊 Statistieken</a>
     33    <% } %>
    3134    <a href="/admin/updates" class="btn">🔄 Updates</a>
    3235  </nav>
Note: See TracChangeset for help on using the changeset viewer.