added basic analytics
All checks were successful
Deploy / Check & Build (push) Successful in 1m14s
Deploy / Deploy to Production (push) Successful in 1m10s

This commit is contained in:
RaineAllDay
2026-03-18 05:32:50 -06:00
parent e6d6cc01da
commit 272ab85b62
4 changed files with 166 additions and 18 deletions

View File

@@ -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} */ /** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) { 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); const response = await resolve(event);
// ── Security headers ──────────────────────────────────────────────────────── // ── Security headers ────────────────────────────────────────────────────────
// Prevent clickjacking
response.headers.set('X-Frame-Options', 'SAMEORIGIN'); response.headers.set('X-Frame-Options', 'SAMEORIGIN');
// Prevent MIME-type sniffing
response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('X-Content-Type-Options', 'nosniff');
// Control referrer information
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Restrict browser features
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); 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', [ response.headers.set('Content-Security-Policy', [
"default-src 'self'", "default-src 'self'",
"script-src 'self' 'unsafe-inline'", "script-src 'self' 'unsafe-inline'",

View File

@@ -112,6 +112,24 @@ function migrate(db) {
CREATE INDEX IF NOT EXISTS idx_messages_read CREATE INDEX IF NOT EXISTS idx_messages_read
ON contact_messages(read, created_at DESC); 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 // Add columns to existing DBs that predate these migrations
@@ -122,6 +140,10 @@ function migrate(db) {
if (!cols.includes('prs_name')) { if (!cols.includes('prs_name')) {
db.exec(`ALTER TABLE personalities ADD COLUMN prs_name TEXT DEFAULT NULL`); 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 // 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 }; 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 // Prevent DB from being instantiated during build
export const db = building ? null : { getDb }; export const db = building ? null : { getDb };

View File

@@ -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 }) { export async function load({ url }) {
const VALID_REPORT_FILTERS = ['open', 'dismissed', 'all']; 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 adminQ = (url.searchParams.get('q') ?? '').slice(0, 200);
const adminPage = Math.max(1, parseInt(url.searchParams.get('page') ?? '1')); 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 resolved = reportFilter === 'all' ? null : reportFilter === 'dismissed' ? 1 : 0;
const reports = listReports({ resolved }); const reports = listReports({ resolved });
@@ -24,6 +25,7 @@ export async function load({ url }) {
return { return {
stats, stats,
views,
reports, reports,
reportFilter, reportFilter,
messages, messages,

View File

@@ -188,6 +188,8 @@
return new Date(iso).toLocaleString(); return new Date(iso).toLocaleString();
} }
$: viewMax = Math.max(...(data.views?.chart ?? []).map(d => d.visitors), 1);
const reasonLabels = { const reasonLabels = {
'incorrect-data': 'Incorrect data', 'incorrect-data': 'Incorrect data',
'duplicate': 'Duplicate', 'duplicate': 'Duplicate',
@@ -218,6 +220,63 @@
{/each} {/each}
</div> </div>
<!-- Visitor stats -->
<div class="panel" style="margin-bottom:24px; overflow:hidden;">
<div style="padding:14px 18px; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:12px;">
<div style="font-family:'Barlow Condensed',sans-serif; font-weight:700; font-size:15px;
letter-spacing:0.06em; text-transform:uppercase; color:var(--text);">
Unique Visitors
</div>
<div style="font-family:'DM Mono',monospace; font-size:11px; color:var(--text3);">
hashed IPs · 6h dedup window · 90 day retention
</div>
</div>
<div style="padding:16px 20px; display:flex; align-items:stretch; gap:24px; flex-wrap:wrap;">
<!-- Counters -->
<div style="display:flex; gap:24px; flex-shrink:0;">
{#each [
{ label: 'Today', value: data.views.todayCount, color: 'var(--amber)' },
{ label: 'Yesterday', value: data.views.yesterdayCount, color: 'var(--text2)' },
{ label: '7-day', value: data.views.weekTotal, color: 'var(--cyan)' },
] as v}
<div>
<div class="label" style="margin-bottom:4px;">{v.label}</div>
<div style="font-family:'DM Mono',monospace; font-size:28px; font-weight:500;
color:{v.color}; text-shadow:0 0 12px {v.color}40; line-height:1;">
{v.value}
</div>
</div>
{/each}
</div>
<!-- Sparkline -->
<div style="flex:1; min-width:200px; display:flex; flex-direction:column; justify-content:flex-end;">
<div style="display:flex; align-items:flex-end; gap:3px; height:48px;">
{#each data.views.chart as day, i}
{@const pct = (day.visitors / viewMax) * 100}
{@const isToday = i === data.views.chart.length - 1}
<div style="flex:1; display:flex; flex-direction:column; align-items:center; gap:3px; height:100%;">
<div style="width:100%; border-radius:2px 2px 0 0;
background:{isToday ? 'var(--amber)' : 'var(--border2)'};
box-shadow:{isToday ? '0 0 8px rgba(232,147,10,0.4)' : 'none'};
height:{Math.max(pct, 4)}%;
margin-top:auto;"
title="{day.date}: {day.visitors} visitor{day.visitors !== 1 ? 's' : ''}">
</div>
</div>
{/each}
</div>
<div style="display:flex; justify-content:space-between; margin-top:4px;
font-family:'DM Mono',monospace; font-size:9px; color:var(--text3);">
<span>{data.views.chart[0]?.date.slice(5)}</span>
<span>today</span>
</div>
</div>
</div>
</div>
<!-- Messages section --> <!-- Messages section -->
<div class="panel" style="margin-bottom:24px; overflow:hidden;"> <div class="panel" style="margin-bottom:24px; overflow:hidden;">
<div style="padding:14px 18px; border-bottom:1px solid var(--border); <div style="padding:14px 18px; border-bottom:1px solid var(--border);