Add simple auth for admin access
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user