From 738cc2a412bd8b502a005fc213e6944b1a3e21d0 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Sun, 12 Apr 2026 13:12:03 -0400 Subject: [PATCH] Add persistent admin analytics, backup restore workflow, and tabbed admin UI --- .gitignore | 1 + README.md | 10 +- server.js | 718 +++++++++++++++++++++++++++++++++++++++++++- src/AdminPage.tsx | 739 +++++++++++++++++++++++++++++++++++++++------- src/App.css | 292 ++++++++++++++++++ src/App.tsx | 56 ++++ 6 files changed, 1707 insertions(+), 109 deletions(-) diff --git a/.gitignore b/.gitignore index 2517be7..dade3e8 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ dist-ssr # the image default and is preserved here for the Docker build context only. # Uncomment the line below if you do NOT want to track live data in git. # data/admin-content.json +data/backups/ # Editor directories and files .vscode/* diff --git a/README.md b/README.md index 42d54c5..d77eba6 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,11 @@ The app will be available at `http://localhost:4173`. Persistent admin saves: - Admin updates are written to `data/admin-content.json`. +- Built-in page hit stats are written to `data/hit-stats.json`. +- Detailed visitor analytics are written to `data/visitor-stats.json`. +- Backup snapshots are written to `data/backups/`. - `docker-compose.yml` mounts `./data` into the container at `/app/data`. -- This keeps your edits after container restarts/rebuilds. +- This keeps all admin-managed data after container restarts/rebuilds/updates. ## Where to edit content @@ -80,6 +83,11 @@ Persistent admin saves: - Enter a URL and click **Scan URL metadata** to auto-fill title, summary, domain, category, and icon when available. - Click **Save project** to apply updates instantly. - Saved edits are written to `data/admin-content.json` through the API server. +- Built-in stats in `/admin` include page hits plus visitor details (IP, country/state/county/city, returning visitors, and recent visitor log). +- Analytics cookies are consent-based. Visitors can accept or decline tracking from the site banner. +- Admin now includes maintenance actions: **Export JSON**, **Backup Now**, **Prune Old Data**, and **Clear Analytics**. +- Admin also supports restoring from a backup snapshot from `/admin`. +- The server creates startup + daily backup snapshots and retains recent backups automatically. - Use the **Theme** dropdown to switch between Sandstone, Ocean, Midnight, Forest, and Sunset. - Use **Remove project** to delete the selected project from your local Admin data. - Use **Reset project** or **Reset all** to restore defaults from `src/data/projects.ts`. diff --git a/server.js b/server.js index 069eec6..ee7cf12 100644 --- a/server.js +++ b/server.js @@ -1,5 +1,6 @@ import express from 'express' -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises' +import { createHash, randomUUID } from 'node:crypto' import path from 'node:path' import { fileURLToPath } from 'node:url' import { Resend } from 'resend' @@ -25,11 +26,589 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const DATA_DIR = path.join(__dirname, 'data') const DATA_FILE = path.join(DATA_DIR, 'admin-content.json') +const HIT_STATS_FILE = path.join(DATA_DIR, 'hit-stats.json') +const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-stats.json') +const BACKUP_DIR = path.join(DATA_DIR, 'backups') const DIST_DIR = path.join(__dirname, 'dist') const INDEX_FILE = path.join(DIST_DIR, 'index.html') +const EMPTY_HIT_STATS = { + totalHits: 0, + firstHitAt: null, + lastHitAt: null, + byPath: {}, + byDay: {}, +} + +let hitStats = { ...EMPTY_HIT_STATS } +let hitStatsWritePromise = Promise.resolve() + +const VISITOR_COOKIE = 'vbn_vid' +const CONSENT_COOKIE = 'vbn_analytics_consent' +const MAX_RECENT_VISITS = 1000 +const VISITOR_RETENTION_DAYS_DEFAULT = 180 +const BACKUP_RETENTION_DAYS = 30 +const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000 + +const EMPTY_VISITOR_STATS = { + totalVisits: 0, + uniqueVisitors: 0, + returningVisits: 0, + firstVisitAt: null, + lastVisitAt: null, + visitors: {}, + recentVisits: [], + geoCacheByIp: {}, +} + +let visitorStats = { ...EMPTY_VISITOR_STATS } +let visitorStatsWritePromise = Promise.resolve() +let lastVisitorStatsWrite = { ok: true, at: null, error: null } +let lastHitStatsWrite = { ok: true, at: null, error: null } +let lastBackupStatus = { ok: true, at: null, error: null, file: null } + +function normalizeIp(rawIp) { + if (!rawIp) return 'unknown' + + let ip = String(rawIp).trim() + + if (ip.includes(',')) { + ip = ip.split(',')[0].trim() + } + + if (ip.startsWith('::ffff:')) { + ip = ip.slice(7) + } + + if (ip === '::1') { + ip = '127.0.0.1' + } + + return ip || 'unknown' +} + +function getClientIp(req) { + const forwarded = req.headers['x-forwarded-for'] + if (forwarded) { + return normalizeIp(forwarded) + } + return normalizeIp(req.ip) +} + +function parseCookies(cookieHeader) { + if (!cookieHeader) return {} + + return cookieHeader + .split(';') + .map(v => v.trim()) + .filter(Boolean) + .reduce((acc, part) => { + const idx = part.indexOf('=') + if (idx === -1) return acc + const key = part.slice(0, idx).trim() + const value = part.slice(idx + 1).trim() + try { + acc[key] = decodeURIComponent(value) + } catch { + acc[key] = value + } + return acc + }, {}) +} + +function hasVisitorConsent(req) { + const cookies = parseCookies(req.headers.cookie) + return cookies[CONSENT_COOKIE] === 'yes' +} + +function setConsentCookie(res, consent) { + const value = consent ? 'yes' : 'no' + res.append('Set-Cookie', `${CONSENT_COOKIE}=${value}; Max-Age=31536000; Path=/; SameSite=Lax`) +} + +function isPrivateOrLocalIp(ip) { + return ( + ip === '127.0.0.1' + || ip === 'localhost' + || ip.startsWith('10.') + || ip.startsWith('192.168.') + || /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip) + || ip.startsWith('fc') + || ip.startsWith('fd') + || ip.startsWith('fe80:') + || ip === 'unknown' + ) +} + +function queueVisitorStatsWrite() { + visitorStatsWritePromise = visitorStatsWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + VISITOR_STATS_FILE, + JSON.stringify({ + ...visitorStats, + updatedAt: new Date().toISOString(), + }, null, 2), + 'utf8', + ) + lastVisitorStatsWrite = { ok: true, at: new Date().toISOString(), error: null } + }) + .catch(err => { + console.error('[visitor-stats] failed to write visitor stats:', err) + lastVisitorStatsWrite = { ok: false, at: new Date().toISOString(), error: String(err) } + }) +} + +function sanitizeUserAgent(userAgent) { + if (!userAgent || typeof userAgent !== 'string') return 'unknown' + return userAgent.trim().slice(0, 300) || 'unknown' +} + +async function resolveGeo(ip) { + if (!ip || isPrivateOrLocalIp(ip)) { + return { + country: 'Local/Unknown', + state: 'Local/Unknown', + county: 'Local/Unknown', + city: 'Local/Unknown', + } + } + + const cached = visitorStats.geoCacheByIp[ip] + if (cached) { + return cached + } + + const providers = [ + async () => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 2500) + try { + const response = await fetch( + `http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,country,regionName,city,district`, + { signal: controller.signal }, + ) + if (!response.ok) return null + const data = await response.json() + if (data?.status !== 'success') return null + return { + country: data?.country || 'Unknown', + state: data?.regionName || 'Unknown', + county: data?.district || 'Unknown', + city: data?.city || 'Unknown', + } + } finally { + clearTimeout(timeout) + } + }, + async () => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 2500) + try { + const response = await fetch(`https://ipwho.is/${encodeURIComponent(ip)}`, { signal: controller.signal }) + if (!response.ok) return null + const data = await response.json() + if (!data?.success) return null + return { + country: data?.country || 'Unknown', + state: data?.region || 'Unknown', + county: data?.region || 'Unknown', + city: data?.city || 'Unknown', + } + } finally { + clearTimeout(timeout) + } + }, + ] + + for (const provider of providers) { + try { + const geo = await provider() + if (geo) { + visitorStats.geoCacheByIp[ip] = geo + queueVisitorStatsWrite() + return geo + } + } catch { + // Try next provider. + } + } + + const fallback = { + country: 'Unknown', + state: 'Unknown', + county: 'Unknown', + city: 'Unknown', + } + visitorStats.geoCacheByIp[ip] = fallback + queueVisitorStatsWrite() + return fallback +} + +async function recordVisitor(req, res) { + const cookies = parseCookies(req.headers.cookie) + let visitorId = cookies[VISITOR_COOKIE] + if (!visitorId) { + visitorId = randomUUID() + res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax`) + } + + const nowIso = new Date().toISOString() + const pathKey = normalizeHitPath(req.path) + const ip = getClientIp(req) + const ua = sanitizeUserAgent(req.get('user-agent')) + + const existingVisitor = visitorStats.visitors[visitorId] + const isReturning = Boolean(existingVisitor) + const geo = await resolveGeo(ip) + + if (!existingVisitor) { + visitorStats.uniqueVisitors += 1 + } else { + visitorStats.returningVisits += 1 + } + + const ipHash = createHash('sha256').update(ip).digest('hex') + const nextVisitCount = (existingVisitor?.visitCount ?? 0) + 1 + const userAgents = Array.from(new Set([...(existingVisitor?.userAgents ?? []), ua])).slice(0, 5) + + visitorStats.visitors[visitorId] = { + visitorId, + ip, + ipHash, + firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso, + lastSeenAt: nowIso, + visitCount: nextVisitCount, + lastPath: pathKey, + returningVisitor: isReturning, + location: geo, + userAgents, + } + + visitorStats.totalVisits += 1 + visitorStats.firstVisitAt = visitorStats.firstVisitAt ?? nowIso + visitorStats.lastVisitAt = nowIso + visitorStats.recentVisits.unshift({ + at: nowIso, + visitorId, + ip, + path: pathKey, + country: geo.country, + state: geo.state, + county: geo.county, + city: geo.city, + returningVisitor: isReturning, + visitCount: nextVisitCount, + }) + visitorStats.recentVisits = visitorStats.recentVisits.slice(0, MAX_RECENT_VISITS) + + queueVisitorStatsWrite() +} + +function loadVisitorStatsFromDisk() { + return readFile(VISITOR_STATS_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + visitorStats = { + totalVisits: Number(parsed?.totalVisits) || 0, + uniqueVisitors: Number(parsed?.uniqueVisitors) || 0, + returningVisits: Number(parsed?.returningVisits) || 0, + firstVisitAt: typeof parsed?.firstVisitAt === 'string' ? parsed.firstVisitAt : null, + lastVisitAt: typeof parsed?.lastVisitAt === 'string' ? parsed.lastVisitAt : null, + visitors: parsed?.visitors && typeof parsed.visitors === 'object' ? parsed.visitors : {}, + recentVisits: Array.isArray(parsed?.recentVisits) ? parsed.recentVisits.slice(0, MAX_RECENT_VISITS) : [], + geoCacheByIp: parsed?.geoCacheByIp && typeof parsed.geoCacheByIp === 'object' ? parsed.geoCacheByIp : {}, + } + }) + .catch(() => { + visitorStats = { ...EMPTY_VISITOR_STATS } + }) +} + +function buildTopLocations(list, key) { + const counts = {} + for (const row of list) { + const val = row?.[key] || 'Unknown' + counts[val] = (counts[val] ?? 0) + 1 + } + return Object.entries(counts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([name, hits]) => ({ name, hits })) +} + +function pruneStatsByDays(daysRaw) { + const days = Number(daysRaw) + const retentionDays = Number.isFinite(days) && days > 0 ? Math.floor(days) : VISITOR_RETENTION_DAYS_DEFAULT + const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000 + + const keepRecent = visitorStats.recentVisits.filter(v => { + const ts = new Date(v.at).getTime() + return Number.isFinite(ts) && ts >= cutoff + }) + + const allowedVisitorIds = new Set(keepRecent.map(v => v.visitorId)) + const nextVisitors = {} + for (const [id, data] of Object.entries(visitorStats.visitors)) { + const lastSeen = new Date(data.lastSeenAt ?? 0).getTime() + if (allowedVisitorIds.has(id) || (Number.isFinite(lastSeen) && lastSeen >= cutoff)) { + nextVisitors[id] = data + } + } + + const nextByDay = {} + for (const [day, count] of Object.entries(hitStats.byDay)) { + const ts = new Date(`${day}T00:00:00.000Z`).getTime() + if (Number.isFinite(ts) && ts >= cutoff) { + nextByDay[day] = count + } + } + + visitorStats.recentVisits = keepRecent + visitorStats.visitors = nextVisitors + visitorStats.uniqueVisitors = Object.keys(nextVisitors).length + visitorStats.totalVisits = keepRecent.length + visitorStats.returningVisits = keepRecent.filter(v => v.returningVisitor).length + visitorStats.firstVisitAt = keepRecent.length > 0 ? keepRecent[keepRecent.length - 1].at : null + visitorStats.lastVisitAt = keepRecent.length > 0 ? keepRecent[0].at : null + + hitStats.byDay = nextByDay + + queueHitStatsWrite() + queueVisitorStatsWrite() + + return { + retentionDays, + remainingVisits: visitorStats.totalVisits, + remainingVisitors: visitorStats.uniqueVisitors, + } +} + +async function createBackupSnapshot(reason = 'scheduled') { + try { + await mkdir(BACKUP_DIR, { recursive: true }) + const stamp = new Date().toISOString().replace(/[:.]/g, '-') + const backupPath = path.join(BACKUP_DIR, `snapshot-${stamp}-${reason}.json`) + + const payload = { + createdAt: new Date().toISOString(), + reason, + adminContent: null, + hitStats, + visitorStats, + } + + try { + const contentRaw = await readFile(DATA_FILE, 'utf8') + payload.adminContent = JSON.parse(contentRaw) + } catch { + payload.adminContent = null + } + + await writeFile(backupPath, JSON.stringify(payload, null, 2), 'utf8') + + const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort() + const maxFiles = BACKUP_RETENTION_DAYS + if (files.length > maxFiles) { + const toDelete = files.slice(0, files.length - maxFiles) + await Promise.all(toDelete.map(name => unlink(path.join(BACKUP_DIR, name)).catch(() => {}))) + } + + lastBackupStatus = { ok: true, at: new Date().toISOString(), error: null, file: path.basename(backupPath) } + } catch (err) { + lastBackupStatus = { ok: false, at: new Date().toISOString(), error: String(err), file: null } + console.error('[backup] failed to create snapshot:', err) + } +} + +async function listBackupFiles() { + await mkdir(BACKUP_DIR, { recursive: true }) + const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort().reverse() + return files +} + +async function readBackupPreview(filename) { + if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) { + throw new Error('Invalid backup filename') + } + + const fullPath = path.join(BACKUP_DIR, filename) + const [fileInfo, raw] = await Promise.all([ + stat(fullPath), + readFile(fullPath, 'utf8'), + ]) + const parsed = JSON.parse(raw) + + return { + filename, + sizeBytes: fileInfo.size, + createdAt: typeof parsed?.createdAt === 'string' ? parsed.createdAt : null, + reason: typeof parsed?.reason === 'string' ? parsed.reason : 'unknown', + adminUpdatedAt: typeof parsed?.adminContent?.updatedAt === 'string' ? parsed.adminContent.updatedAt : null, + totalHits: Number(parsed?.hitStats?.totalHits) || 0, + totalVisits: Number(parsed?.visitorStats?.totalVisits) || 0, + } +} + +async function listBackupPreviews() { + const files = await listBackupFiles() + const previews = await Promise.all(files.map(async filename => { + try { + return await readBackupPreview(filename) + } catch { + return { + filename, + sizeBytes: 0, + createdAt: null, + reason: 'unknown', + adminUpdatedAt: null, + totalHits: 0, + totalVisits: 0, + } + } + })) + return previews +} + +function sanitizeLoadedHitStats(value) { + return { + totalHits: Number(value?.totalHits) || 0, + firstHitAt: typeof value?.firstHitAt === 'string' ? value.firstHitAt : null, + lastHitAt: typeof value?.lastHitAt === 'string' ? value.lastHitAt : null, + byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {}, + byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {}, + } +} + +function sanitizeLoadedVisitorStats(value) { + return { + totalVisits: Number(value?.totalVisits) || 0, + uniqueVisitors: Number(value?.uniqueVisitors) || 0, + returningVisits: Number(value?.returningVisits) || 0, + firstVisitAt: typeof value?.firstVisitAt === 'string' ? value.firstVisitAt : null, + lastVisitAt: typeof value?.lastVisitAt === 'string' ? value.lastVisitAt : null, + visitors: value?.visitors && typeof value.visitors === 'object' ? value.visitors : {}, + recentVisits: Array.isArray(value?.recentVisits) ? value.recentVisits.slice(0, MAX_RECENT_VISITS) : [], + geoCacheByIp: value?.geoCacheByIp && typeof value.geoCacheByIp === 'object' ? value.geoCacheByIp : {}, + } +} + +async function restoreFromBackup(filename) { + if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) { + throw new Error('Invalid backup filename') + } + + const fullPath = path.join(BACKUP_DIR, filename) + const raw = await readFile(fullPath, 'utf8') + const parsed = JSON.parse(raw) + + await createBackupSnapshot('pre-restore') + + if (parsed?.adminContent && typeof parsed.adminContent === 'object') { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile(DATA_FILE, JSON.stringify(parsed.adminContent, null, 2), 'utf8') + } + + hitStats = sanitizeLoadedHitStats(parsed?.hitStats) + visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats) + + queueHitStatsWrite() + queueVisitorStatsWrite() + + await Promise.all([hitStatsWritePromise, visitorStatsWritePromise]) + await createBackupSnapshot('post-restore') +} + +function normalizeHitPath(pathname) { + if (!pathname || pathname === '') return '/' + if (pathname.length > 1 && pathname.endsWith('/')) { + return pathname.slice(0, -1) + } + return pathname +} + +function shouldCountHit(req) { + if (req.method !== 'GET') return false + if (req.path.startsWith('/api/')) return false + if (req.path === '/favicon.ico') return false + + // Ignore direct asset requests and only count document-like requests. + const hasFileExt = path.extname(req.path) !== '' + if (hasFileExt) return false + + const accept = req.get('accept') ?? '' + return accept.includes('text/html') || accept === '*/*' || accept === '' +} + +function queueHitStatsWrite() { + hitStatsWritePromise = hitStatsWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + HIT_STATS_FILE, + JSON.stringify({ + ...hitStats, + updatedAt: new Date().toISOString(), + }, null, 2), + 'utf8', + ) + lastHitStatsWrite = { ok: true, at: new Date().toISOString(), error: null } + }) + .catch(err => { + console.error('[stats] failed to write hit stats:', err) + lastHitStatsWrite = { ok: false, at: new Date().toISOString(), error: String(err) } + }) +} + +function recordHit(pathname) { + const nowIso = new Date().toISOString() + const dayKey = nowIso.slice(0, 10) + const safePath = normalizeHitPath(pathname) + + hitStats.totalHits += 1 + hitStats.lastHitAt = nowIso + hitStats.firstHitAt = hitStats.firstHitAt ?? nowIso + hitStats.byPath[safePath] = (hitStats.byPath[safePath] ?? 0) + 1 + hitStats.byDay[dayKey] = (hitStats.byDay[dayKey] ?? 0) + 1 + + queueHitStatsWrite() +} + +function buildLastNDaysStats(days) { + const out = [] + const today = new Date() + + for (let i = days - 1; i >= 0; i -= 1) { + const d = new Date(today) + d.setDate(today.getDate() - i) + const dayKey = d.toISOString().slice(0, 10) + out.push({ day: dayKey, hits: hitStats.byDay[dayKey] ?? 0 }) + } + + return out +} + +function loadHitStatsFromDisk() { + return readFile(HIT_STATS_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + hitStats = { + totalHits: Number(parsed?.totalHits) || 0, + firstHitAt: typeof parsed?.firstHitAt === 'string' ? parsed.firstHitAt : null, + lastHitAt: typeof parsed?.lastHitAt === 'string' ? parsed.lastHitAt : null, + byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {}, + byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {}, + } + }) + .catch(() => { + hitStats = { ...EMPTY_HIT_STATS } + }) +} + const app = express() app.use(express.json({ limit: '10mb' })) +app.set('trust proxy', true) app.get('/api/admin-content', async (_req, res) => { try { @@ -63,6 +642,126 @@ app.put('/api/admin-content', async (req, res) => { } }) +app.post('/api/analytics-consent', (req, res) => { + const consent = req.body?.consent === true + setConsentCookie(res, consent) + res.json({ ok: true, consent }) +}) + +app.get('/api/admin-stats', (_req, res) => { + const topPaths = Object.entries(hitStats.byPath) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([pathKey, hits]) => ({ path: pathKey, hits })) + + const recentVisitorRows = visitorStats.recentVisits.slice(0, 100) + + res.json({ + totalHits: hitStats.totalHits, + firstHitAt: hitStats.firstHitAt, + lastHitAt: hitStats.lastHitAt, + topPaths, + last7Days: buildLastNDaysStats(7), + last30DaysTotal: buildLastNDaysStats(30).reduce((sum, item) => sum + item.hits, 0), + visitors: { + totalVisits: visitorStats.totalVisits, + uniqueVisitors: visitorStats.uniqueVisitors, + returningVisits: visitorStats.returningVisits, + firstVisitAt: visitorStats.firstVisitAt, + lastVisitAt: visitorStats.lastVisitAt, + topCountries: buildTopLocations(recentVisitorRows, 'country'), + topStates: buildTopLocations(recentVisitorRows, 'state'), + topCounties: buildTopLocations(recentVisitorRows, 'county'), + topCities: buildTopLocations(recentVisitorRows, 'city'), + recentVisits: recentVisitorRows, + }, + writeStatus: { + hitStats: lastHitStatsWrite, + visitorStats: lastVisitorStatsWrite, + backups: lastBackupStatus, + }, + }) +}) + +app.get('/api/admin-stats/export', async (_req, res) => { + let adminContent = null + try { + const raw = await readFile(DATA_FILE, 'utf8') + adminContent = JSON.parse(raw) + } catch { + adminContent = null + } + + res.json({ + exportedAt: new Date().toISOString(), + adminContent, + hitStats, + visitorStats, + }) +}) + +app.post('/api/admin-stats/clear', (_req, res) => { + hitStats = { ...EMPTY_HIT_STATS } + visitorStats = { ...EMPTY_VISITOR_STATS } + queueHitStatsWrite() + queueVisitorStatsWrite() + createBackupSnapshot('post-clear').catch(() => {}) + res.json({ ok: true }) +}) + +app.post('/api/admin-stats/prune', (req, res) => { + const result = pruneStatsByDays(req.body?.days) + createBackupSnapshot('post-prune').catch(() => {}) + res.json({ ok: true, ...result }) +}) + +app.post('/api/admin-stats/backup', async (_req, res) => { + await createBackupSnapshot('manual') + res.json({ ok: true, backup: lastBackupStatus }) +}) + +app.get('/api/admin-stats/backups', async (_req, res) => { + try { + const backups = await listBackupPreviews() + res.json({ backups }) + } catch { + res.status(500).json({ message: 'Could not list backups.' }) + } +}) + +app.post('/api/admin-stats/backup-preview', async (req, res) => { + try { + const { filename } = req.body ?? {} + const preview = await readBackupPreview(filename) + res.json({ preview }) + } catch (err) { + res.status(400).json({ message: err instanceof Error ? err.message : 'Could not load backup preview.' }) + } +}) + +app.post('/api/admin-stats/restore', async (req, res) => { + try { + const { filename } = req.body ?? {} + await restoreFromBackup(filename) + const backups = await listBackupPreviews() + res.json({ ok: true, restored: filename, backups }) + } catch (err) { + res.status(400).json({ message: err instanceof Error ? err.message : 'Restore failed.' }) + } +}) + +app.use((req, res, next) => { + if (shouldCountHit(req)) { + recordHit(req.path) + if (hasVisitorConsent(req)) { + recordVisitor(req, res).catch(err => { + console.error('[visitor-stats] failed to record visitor:', err) + }) + } + } + next() +}) + // Rate-limit contact submissions: max 5 per IP per 10 minutes const contactHits = new Map() function contactRateLimit(req, res, next) { @@ -217,6 +916,17 @@ app.use(async (_req, res) => { }) const PORT = Number(process.env.PORT ?? 4173) -app.listen(PORT, () => { - console.log(`Portfolio app listening on http://localhost:${PORT}`) -}) +Promise.all([loadHitStatsFromDisk(), loadVisitorStatsFromDisk()]) + .catch(err => { + console.error('[stats] failed to load persisted stats:', err) + }) + .finally(() => { + createBackupSnapshot('startup').catch(() => {}) + setInterval(() => { + createBackupSnapshot('scheduled').catch(() => {}) + }, BACKUP_INTERVAL_MS) + + app.listen(PORT, () => { + console.log(`Portfolio app listening on http://localhost:${PORT}`) + }) + }) diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 4395d15..f4bda35 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' import type { SiteContent, CustomLink, CustomBlock } from './App' import { DEFAULTS } from './App' @@ -8,6 +8,53 @@ interface Props { onSave: (c: SiteContent) => void } +interface BackupPreview { + filename: string + sizeBytes: number + createdAt: string | null + reason: string + adminUpdatedAt: string | null + totalHits: number + totalVisits: number +} + +interface AdminStats { + totalHits: number + firstHitAt: string | null + lastHitAt: string | null + topPaths: Array<{ path: string; hits: number }> + last7Days: Array<{ day: string; hits: number }> + last30DaysTotal: number + visitors: { + totalVisits: number + uniqueVisitors: number + returningVisits: number + firstVisitAt: string | null + lastVisitAt: string | null + topCountries: Array<{ name: string; hits: number }> + topStates: Array<{ name: string; hits: number }> + topCounties: Array<{ name: string; hits: number }> + topCities: Array<{ name: string; hits: number }> + recentVisits: Array<{ + at: string + visitorId: string + ip: string + path: string + country: string + state: string + county: string + city: string + returningVisitor: boolean + visitCount: number + }> + } + writeStatus: { + hitStats: { ok: boolean; at: string | null; error: string | null } + visitorStats: { ok: boolean; at: string | null; error: string | null } + backups: { ok: boolean; at: string | null; error: string | null; file: string | null } + } +} + type StringField = Exclude const FIELDS: { key: StringField; label: string; multiline?: boolean }[] = [ @@ -31,6 +78,194 @@ export default function AdminPage({ content, onSave }: Props) { const [form, setForm] = useState(content) const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [errorMsg, setErrorMsg] = useState('') + const [adminTab, setAdminTab] = useState<'content' | 'stats'>('content') + const [contentTab, setContentTab] = useState<'main' | 'custom'>('main') + const [stats, setStats] = useState(null) + const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading') + const [maintenanceMsg, setMaintenanceMsg] = useState('') + const [backupFiles, setBackupFiles] = useState([]) + const [selectedBackup, setSelectedBackup] = useState('') + const [selectedBackupPreview, setSelectedBackupPreview] = useState(null) + + useEffect(() => { + fetch('/api/admin-stats') + .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load stats')))) + .then(data => { + setStats(data as AdminStats) + setStatsStatus('ready') + }) + .catch(() => { + setStatsStatus('error') + }) + + fetch('/api/admin-stats/backups') + .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load backups')))) + .then(data => { + const files = Array.isArray((data as { backups?: unknown }).backups) ? (data as { backups: BackupPreview[] }).backups : [] + setBackupFiles(files) + if (files.length > 0) { + setSelectedBackup(files[0].filename) + } + }) + .catch(() => {}) + }, []) + + useEffect(() => { + if (!selectedBackup) { + setSelectedBackupPreview(null) + return + } + + fetch('/api/admin-stats/backup-preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: selectedBackup }), + }) + .then(r => (r.ok ? r.json() : Promise.reject(new Error('Preview failed')))) + .then(data => { + const preview = (data as { preview?: BackupPreview }).preview ?? null + setSelectedBackupPreview(preview) + }) + .catch(() => { + setSelectedBackupPreview(null) + }) + }, [selectedBackup]) + + async function reloadStats() { + const r = await fetch('/api/admin-stats') + if (!r.ok) throw new Error('Could not refresh stats') + const data = await r.json() + setStats(data as AdminStats) + setStatsStatus('ready') + } + + async function reloadBackups() { + const r = await fetch('/api/admin-stats/backups') + if (!r.ok) throw new Error('Could not refresh backups') + const data = await r.json() as { backups?: BackupPreview[] } + const files = Array.isArray(data.backups) ? data.backups : [] + setBackupFiles(files) + const names = files.map(f => f.filename) + if (files.length > 0 && !names.includes(selectedBackup)) { + setSelectedBackup(files[0].filename) + } + } + + async function reloadContentFromServer() { + const r = await fetch('/api/admin-content') + if (!r.ok) return + const data = await r.json() as { siteContent?: Partial } + if (data?.siteContent && typeof data.siteContent === 'object') { + const next = { ...DEFAULTS, ...data.siteContent } + setForm(next) + onSave(next) + } + } + + function maskIp(ip: string) { + if (!ip || ip === 'unknown') return 'unknown' + if (ip.includes('.')) { + const parts = ip.split('.') + if (parts.length === 4) return `${parts[0]}.${parts[1]}.x.x` + } + if (ip.includes(':')) { + const parts = ip.split(':') + return `${parts.slice(0, 3).join(':')}:x:x` + } + return ip + } + + async function handleExport() { + try { + const r = await fetch('/api/admin-stats/export') + if (!r.ok) throw new Error('Export failed') + const data = await r.json() + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `siteforge-admin-export-${new Date().toISOString().slice(0, 10)}.json` + a.click() + URL.revokeObjectURL(url) + setMaintenanceMsg('Export downloaded.') + } catch { + setMaintenanceMsg('Export failed.') + } + } + + async function handlePrune() { + const input = prompt('Keep how many days of analytics data?', '180') + if (input === null) return + const days = Number(input) + if (!Number.isFinite(days) || days <= 0) { + setMaintenanceMsg('Invalid retention days.') + return + } + try { + const r = await fetch('/api/admin-stats/prune', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ days }), + }) + if (!r.ok) throw new Error('Prune failed') + await reloadStats() + setMaintenanceMsg(`Pruned analytics to ${Math.floor(days)} days.`) + } catch { + setMaintenanceMsg('Prune failed.') + } + } + + async function handleClear() { + if (!confirm('Clear ALL analytics data now? This cannot be undone.')) return + try { + const r = await fetch('/api/admin-stats/clear', { method: 'POST' }) + if (!r.ok) throw new Error('Clear failed') + await reloadStats() + setMaintenanceMsg('All analytics data cleared.') + } catch { + setMaintenanceMsg('Clear failed.') + } + } + + async function handleBackupNow() { + try { + const r = await fetch('/api/admin-stats/backup', { method: 'POST' }) + if (!r.ok) throw new Error('Backup failed') + await reloadStats() + await reloadBackups() + setMaintenanceMsg('Backup snapshot created.') + } catch { + setMaintenanceMsg('Backup failed.') + } + } + + async function handleRestoreBackup() { + if (!selectedBackup) { + setMaintenanceMsg('Select a backup first.') + return + } + if (!confirm(`Restore backup ${selectedBackup}? This will overwrite current admin data and analytics.`)) return + try { + const r = await fetch('/api/admin-stats/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: selectedBackup }), + }) + if (!r.ok) throw new Error('Restore failed') + await reloadContentFromServer() + await reloadStats() + await reloadBackups() + setMaintenanceMsg(`Restored from ${selectedBackup}. Content fields were refreshed from backup.`) + } catch { + setMaintenanceMsg('Restore failed.') + } + } + + function formatDate(value: string | null) { + if (!value) return 'Not available yet' + const d = new Date(value) + return Number.isNaN(d.getTime()) ? 'Not available yet' : d.toLocaleString() + } function handleChange(key: StringField, value: string) { setForm(f => ({ ...f, [key]: value })) @@ -117,118 +352,413 @@ export default function AdminPage({ content, onSave }: Props) {
+
+ + +
+ + {adminTab === 'stats' && ( +
+
+

Site Hit Stats

+

Built-in page traffic and visitor intelligence from this server.

+
+ {statsStatus === 'loading' &&

Loading stats...

} + {statsStatus === 'error' &&

Could not load stats right now.

} + {statsStatus === 'ready' && stats && ( + <> +
+
+

Total Hits

+

{stats.totalHits.toLocaleString()}

+
+
+

Last 30 Days

+

{stats.last30DaysTotal.toLocaleString()}

+
+
+

First Hit

+

{formatDate(stats.firstHitAt)}

+
+
+

Latest Hit

+

{formatDate(stats.lastHitAt)}

+
+
+ +
+
+

Top Paths

+ {stats.topPaths.length === 0 ? ( +

No hits tracked yet.

+ ) : ( +
    + {stats.topPaths.map(item => ( +
  • + {item.path} + {item.hits.toLocaleString()} +
  • + ))} +
+ )} +
+
+

Daily Hits (7 Days)

+
    + {stats.last7Days.map(item => ( +
  • + {item.day} + {item.hits.toLocaleString()} +
  • + ))} +
+
+
+ +
+

Visitor Details

+

IP, geography, and returning visitor behavior.

+
+ +

+ Privacy: visitor analytics only run after cookie consent. IPs below are masked. +

+ +
+
+

Total Visits

+

{stats.visitors.totalVisits.toLocaleString()}

+
+
+

Unique Visitors

+

{stats.visitors.uniqueVisitors.toLocaleString()}

+
+
+

Returning Visits

+

{stats.visitors.returningVisits.toLocaleString()}

+
+
+

Returning Rate

+

+ {stats.visitors.totalVisits > 0 + ? `${Math.round((stats.visitors.returningVisits / stats.visitors.totalVisits) * 100)}%` + : '0%'} +

+
+
+ +
+
+

Top Countries

+
    + {stats.visitors.topCountries.map(item => ( +
  • + {item.name} + {item.hits.toLocaleString()} +
  • + ))} +
+
+
+

Top States

+
    + {stats.visitors.topStates.map(item => ( +
  • + {item.name} + {item.hits.toLocaleString()} +
  • + ))} +
+
+
+

Top Counties

+
    + {stats.visitors.topCounties.map(item => ( +
  • + {item.name} + {item.hits.toLocaleString()} +
  • + ))} +
+
+
+

Top Cities

+
    + {stats.visitors.topCities.map(item => ( +
  • + {item.name} + {item.hits.toLocaleString()} +
  • + ))} +
+
+
+ +
+

Recent Visitor Log

+ {stats.visitors.recentVisits.length === 0 ? ( +

No visitor records yet.

+ ) : ( +
+ + + + + + + + + + + + + + + + {stats.visitors.recentVisits.map(row => ( + + + + + + + + + + + + ))} + +
TimeIPCountryStateCountyCityPathReturningVisit #
{formatDate(row.at)}{maskIp(row.ip)}{row.country}{row.state}{row.county}{row.city}{row.path}{row.returningVisitor ? 'Yes' : 'No'}{row.visitCount}
+
+ )} +
+ +
+

Data Management

+

Export, backup, or retain only recent analytics data.

+
+ +
+
+

Hit Stats Write

+

{stats.writeStatus.hitStats.ok ? 'Healthy' : 'Error'}

+

{formatDate(stats.writeStatus.hitStats.at)}

+
+
+

Visitor Stats Write

+

{stats.writeStatus.visitorStats.ok ? 'Healthy' : 'Error'}

+

{formatDate(stats.writeStatus.visitorStats.at)}

+
+
+

Backup Status

+

{stats.writeStatus.backups.ok ? 'Healthy' : 'Error'}

+

{formatDate(stats.writeStatus.backups.at)}

+
+
+

Latest Backup File

+

{stats.writeStatus.backups.file ?? 'Not available yet'}

+
+
+ +
+ + + + +
+ +
+ + + +
+ {selectedBackupPreview && ( +
+

Restore Preview

+

Backup: {selectedBackupPreview.filename}

+

Created: {formatDate(selectedBackupPreview.createdAt)}

+

Reason: {selectedBackupPreview.reason}

+

Size: {(selectedBackupPreview.sizeBytes / 1024).toFixed(1)} KB

+

Content Updated At: {formatDate(selectedBackupPreview.adminUpdatedAt)}

+

Total Hits: {selectedBackupPreview.totalHits.toLocaleString()}

+

Total Visits: {selectedBackupPreview.totalVisits.toLocaleString()}

+
+ )} + {maintenanceMsg &&

{maintenanceMsg}

} + + )} +
+ )} + + {adminTab === 'content' && (
{ e.preventDefault(); handleSave() }} > - {FIELDS.map(({ key, label, multiline }) => ( -
- - {multiline ? ( -