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} */
|
||||
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'",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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'];
|
||||
@@ -12,6 +12,7 @@ export async function load({ url }) {
|
||||
const adminPage = Math.max(1, parseInt(url.searchParams.get('page') ?? '1'));
|
||||
|
||||
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,
|
||||
|
||||
@@ -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}
|
||||
</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 -->
|
||||
<div class="panel" style="margin-bottom:24px; overflow:hidden;">
|
||||
<div style="padding:14px 18px; border-bottom:1px solid var(--border);
|
||||
|
||||
Reference in New Issue
Block a user