Add simple auth for admin access

This commit is contained in:
nmemmert
2026-04-12 13:34:11 -04:00
parent f07d8fecfb
commit 770d2357ac
5 changed files with 287 additions and 12 deletions
+89 -9
View File
@@ -46,10 +46,13 @@ let hitStatsWritePromise = Promise.resolve()
const VISITOR_COOKIE = 'vbn_vid'
const CONSENT_COOKIE = 'vbn_analytics_consent'
const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
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 ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'change-me-admin-password'
const EMPTY_VISITOR_STATS = {
totalVisits: 0,
@@ -71,6 +74,47 @@ let contactSubmissionsWritePromise = 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 }
const adminSessions = new Map()
function sha256(value) {
return createHash('sha256').update(value).digest('hex')
}
function isAdminPasswordConfigured() {
return ADMIN_PASSWORD !== 'change-me-admin-password'
}
function isValidAdminSession(req) {
const cookies = parseCookies(req.headers.cookie)
const sessionToken = cookies[ADMIN_SESSION_COOKIE]
if (!sessionToken) return false
const expiresAt = adminSessions.get(sessionToken)
if (!expiresAt) return false
if (expiresAt <= Date.now()) {
adminSessions.delete(sessionToken)
return false
}
adminSessions.set(sessionToken, Date.now() + ADMIN_SESSION_TTL_MS)
return true
}
function setAdminSessionCookie(res, token) {
res.append('Set-Cookie', `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax`)
}
function clearAdminSessionCookie(res) {
res.append('Set-Cookie', `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`)
}
function requireAdminAuth(req, res, next) {
if (!isValidAdminSession(req)) {
res.status(401).json({ message: 'Unauthorized' })
return
}
next()
}
function normalizeIp(rawIp) {
if (!rawIp) return 'unknown'
@@ -686,7 +730,43 @@ app.get('/api/admin-content', async (_req, res) => {
}
})
app.put('/api/admin-content', async (req, res) => {
app.get('/api/admin-auth/status', (req, res) => {
res.json({
authenticated: isValidAdminSession(req),
configured: isAdminPasswordConfigured(),
})
})
app.post('/api/admin-auth/login', (req, res) => {
const password = typeof req.body?.password === 'string' ? req.body.password : ''
if (!isAdminPasswordConfigured()) {
res.status(503).json({ message: 'ADMIN_PASSWORD is not configured on the server.' })
return
}
if (sha256(password) !== sha256(ADMIN_PASSWORD)) {
res.status(401).json({ message: 'Invalid password.' })
return
}
const sessionToken = randomUUID()
adminSessions.set(sessionToken, Date.now() + ADMIN_SESSION_TTL_MS)
setAdminSessionCookie(res, sessionToken)
res.json({ ok: true })
})
app.post('/api/admin-auth/logout', (req, res) => {
const cookies = parseCookies(req.headers.cookie)
const sessionToken = cookies[ADMIN_SESSION_COOKIE]
if (sessionToken) {
adminSessions.delete(sessionToken)
}
clearAdminSessionCookie(res)
res.json({ ok: true })
})
app.put('/api/admin-content', requireAdminAuth, async (req, res) => {
try {
const { siteContent } = req.body ?? {}
@@ -714,7 +794,7 @@ app.post('/api/analytics-consent', (req, res) => {
res.json({ ok: true, consent })
})
app.get('/api/admin-stats', (_req, res) => {
app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
const topPaths = Object.entries(hitStats.byPath)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
@@ -757,7 +837,7 @@ app.get('/api/admin-stats', (_req, res) => {
})
})
app.get('/api/admin-stats/export', async (_req, res) => {
app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => {
let adminContent = null
try {
const raw = await readFile(DATA_FILE, 'utf8')
@@ -775,7 +855,7 @@ app.get('/api/admin-stats/export', async (_req, res) => {
})
})
app.post('/api/admin-stats/clear', (_req, res) => {
app.post('/api/admin-stats/clear', requireAdminAuth, (_req, res) => {
hitStats = { ...EMPTY_HIT_STATS }
visitorStats = { ...EMPTY_VISITOR_STATS }
queueHitStatsWrite()
@@ -784,18 +864,18 @@ app.post('/api/admin-stats/clear', (_req, res) => {
res.json({ ok: true })
})
app.post('/api/admin-stats/prune', (req, res) => {
app.post('/api/admin-stats/prune', requireAdminAuth, (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) => {
app.post('/api/admin-stats/backup', requireAdminAuth, async (_req, res) => {
await createBackupSnapshot('manual')
res.json({ ok: true, backup: lastBackupStatus })
})
app.get('/api/admin-stats/backups', async (_req, res) => {
app.get('/api/admin-stats/backups', requireAdminAuth, async (_req, res) => {
try {
const backups = await listBackupPreviews()
res.json({ backups })
@@ -804,7 +884,7 @@ app.get('/api/admin-stats/backups', async (_req, res) => {
}
})
app.post('/api/admin-stats/backup-preview', async (req, res) => {
app.post('/api/admin-stats/backup-preview', requireAdminAuth, async (req, res) => {
try {
const { filename } = req.body ?? {}
const preview = await readBackupPreview(filename)
@@ -814,7 +894,7 @@ app.post('/api/admin-stats/backup-preview', async (req, res) => {
}
})
app.post('/api/admin-stats/restore', async (req, res) => {
app.post('/api/admin-stats/restore', requireAdminAuth, async (req, res) => {
try {
const { filename } = req.body ?? {}
await restoreFromBackup(filename)