added basic analytics
This commit is contained in:
@@ -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'",
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user