diff --git a/src/hooks.server.js b/src/hooks.server.js index 7c89495..9beb1a2 100644 --- a/src/hooks.server.js +++ b/src/hooks.server.js @@ -1,28 +1,38 @@ +import { createHash } from 'crypto'; +import { recordPageView } from '$lib/server/db.js'; +import { building } from '$app/environment'; +import { getClientIp } from '$lib/server/ratelimit.js'; + /** @type {import('@sveltejs/kit').Handle} */ export async function handle({ event, resolve }) { + // ── Page view tracking ───────────────────────────────────────────────────── + // Only track real page requests — skip API, admin, assets, and internal routes + if (!building) { + const path = event.url.pathname; + const isPage = !path.startsWith('/api/') && + !path.startsWith('/admin') && + !path.startsWith('/_') && + !path.includes('.'); + + if (isPage && event.request.method === 'GET') { + try { + const ip = getClientIp(event.request); + // Hash IP + today's date as salt — can't reverse to get IP, + // and rotates daily so long-term tracking isn't possible + const today = new Date().toISOString().slice(0, 10); + const ipHash = createHash('sha256').update(`${ip}:${today}`).digest('hex').slice(0, 16); + recordPageView({ ipHash, path }); + } catch { /* never let analytics break the request */ } + } + } + const response = await resolve(event); // ── Security headers ──────────────────────────────────────────────────────── - // Prevent clickjacking response.headers.set('X-Frame-Options', 'SAMEORIGIN'); - - // Prevent MIME-type sniffing response.headers.set('X-Content-Type-Options', 'nosniff'); - - // Control referrer information response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); - - // Restrict browser features response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); - - // Content Security Policy - // - default-src 'self': only load resources from our own origin - // - script-src 'self' 'unsafe-inline': SvelteKit needs inline scripts for hydration - // - style-src 'self' 'unsafe-inline' fonts.googleapis.com: inline styles + Google Fonts CSS - // - font-src 'self' fonts.gstatic.com: Google Fonts files - // - img-src 'self' data:: allow data URIs for any inline images - // - connect-src 'self': API calls only to our own origin - // - frame-ancestors 'none': belt-and-suspenders against clickjacking response.headers.set('Content-Security-Policy', [ "default-src 'self'", "script-src 'self' 'unsafe-inline'", diff --git a/src/lib/server/db.js b/src/lib/server/db.js index 1935b46..94b6d62 100644 --- a/src/lib/server/db.js +++ b/src/lib/server/db.js @@ -112,6 +112,24 @@ function migrate(db) { CREATE INDEX IF NOT EXISTS idx_messages_read ON contact_messages(read, created_at DESC); + + -- Page views: privacy-friendly unique visitor tracking + -- ip_hash: SHA-256 of IP + daily salt (never stores raw IP) + -- bucket: YYYY-MM-DD-HH rounded to 6-hour windows (00,06,12,18) + -- A visitor only counts once per 6-hour bucket per day + CREATE TABLE IF NOT EXISTS page_views ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, -- YYYY-MM-DD + bucket TEXT NOT NULL, -- YYYY-MM-DD-HH (6h window) + ip_hash TEXT NOT NULL, -- hashed, never raw + path TEXT NOT NULL + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_page_views_dedup + ON page_views(bucket, ip_hash); + + CREATE INDEX IF NOT EXISTS idx_page_views_date + ON page_views(date); `); // Add columns to existing DBs that predate these migrations @@ -122,6 +140,10 @@ function migrate(db) { if (!cols.includes('prs_name')) { db.exec(`ALTER TABLE personalities ADD COLUMN prs_name TEXT DEFAULT NULL`); } + + // Purge page_views older than 90 days to keep the table lean + const viewCutoff = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + db.prepare(`DELETE FROM page_views WHERE date < ?`).run(viewCutoff); } // Purge expired sessions and hard-delete soft-deleted personalities older than 60 days @@ -434,5 +456,60 @@ export function listRecentPersonalitiesAdmin({ page = 1, limit = 25, q = '' } = return { rows, total }; } +// ── Page view tracking ─────────────────────────────────────────── + +export function recordPageView({ ipHash, path, now = new Date() }) { + const db = getDb(); + const date = now.toISOString().slice(0, 10); // YYYY-MM-DD + const hour = now.getUTCHours(); + const window = hour < 6 ? '00' : hour < 12 ? '06' : hour < 18 ? '12' : '18'; + const bucket = `${date}-${window}`; + + // INSERT OR IGNORE — the unique index on (bucket, ip_hash) deduplicates + db.prepare(` + INSERT OR IGNORE INTO page_views (date, bucket, ip_hash, path) + VALUES (?, ?, ?, ?) + `).run(date, bucket, ipHash, path); +} + +export function getViewStats() { + const db = getDb(); + const now = new Date(); + const today = now.toISOString().slice(0, 10); + const yesterday = new Date(now - 86400000).toISOString().slice(0, 10); + const weekAgo = new Date(now - 7 * 86400000).toISOString().slice(0, 10); + + const todayCount = db.prepare( + `SELECT COUNT(DISTINCT ip_hash) as n FROM page_views WHERE date = ?` + ).get(today).n; + + const yesterdayCount = db.prepare( + `SELECT COUNT(DISTINCT ip_hash) as n FROM page_views WHERE date = ?` + ).get(yesterday).n; + + const weekTotal = db.prepare( + `SELECT COUNT(DISTINCT ip_hash) as n FROM page_views WHERE date >= ?` + ).get(weekAgo).n; + + // Per-day counts for the last 7 days for the sparkline + const daily = db.prepare(` + SELECT date, COUNT(DISTINCT ip_hash) as visitors + FROM page_views + WHERE date >= ? + GROUP BY date + ORDER BY date ASC + `).all(weekAgo); + + // Fill in missing days with 0 + const chart = []; + for (let i = 6; i >= 0; i--) { + const d = new Date(now - i * 86400000).toISOString().slice(0, 10); + const found = daily.find(r => r.date === d); + chart.push({ date: d, visitors: found ? found.visitors : 0 }); + } + + return { todayCount, yesterdayCount, weekTotal, chart }; +} + // Prevent DB from being instantiated during build export const db = building ? null : { getDb }; diff --git a/src/routes/admin/+page.server.js b/src/routes/admin/+page.server.js index 5e7693d..d926542 100644 --- a/src/routes/admin/+page.server.js +++ b/src/routes/admin/+page.server.js @@ -1,4 +1,4 @@ -import { getAdminStats, listReports, listRecentPersonalitiesAdmin, listContactMessages } from '$lib/server/db.js'; +import { getAdminStats, listReports, listRecentPersonalitiesAdmin, listContactMessages, getViewStats } from '$lib/server/db.js'; export async function load({ url }) { const VALID_REPORT_FILTERS = ['open', 'dismissed', 'all']; @@ -11,7 +11,8 @@ export async function load({ url }) { const adminQ = (url.searchParams.get('q') ?? '').slice(0, 200); const adminPage = Math.max(1, parseInt(url.searchParams.get('page') ?? '1')); - const stats = getAdminStats(); + const stats = getAdminStats(); + const views = getViewStats(); const resolved = reportFilter === 'all' ? null : reportFilter === 'dismissed' ? 1 : 0; const reports = listReports({ resolved }); @@ -24,6 +25,7 @@ export async function load({ url }) { return { stats, + views, reports, reportFilter, messages, diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte index 07ed56d..7f97468 100644 --- a/src/routes/admin/+page.svelte +++ b/src/routes/admin/+page.svelte @@ -188,6 +188,8 @@ return new Date(iso).toLocaleString(); } + $: viewMax = Math.max(...(data.views?.chart ?? []).map(d => d.visitors), 1); + const reasonLabels = { 'incorrect-data': 'Incorrect data', 'duplicate': 'Duplicate', @@ -218,6 +220,63 @@ {/each} + +
+