Add persistent admin analytics, backup restore workflow, and tabbed admin UI
This commit is contained in:
@@ -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}`)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user