Make homepage study card admin-editable with NEW tag controls
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import express from 'express'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Resend } from 'resend'
|
||||
@@ -53,6 +53,8 @@ const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-stats.json')
|
||||
const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json')
|
||||
const QUESTIONS_FILE = path.join(DATA_DIR, 'questions.json')
|
||||
const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json')
|
||||
const STUDY_USERS_FILE = path.join(DATA_DIR, 'study-users.json')
|
||||
const STUDY_NOTES_FILE = path.join(DATA_DIR, 'study-notes.json')
|
||||
const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
|
||||
const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
|
||||
const BACKUP_DIR = path.join(DATA_DIR, 'backups')
|
||||
@@ -381,12 +383,21 @@ const DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000
|
||||
const titusDownloadTokens = new Map()
|
||||
|
||||
const MAX_QUESTIONS = 1000
|
||||
const MAX_STUDY_USERS = 5000
|
||||
const MAX_STUDY_NOTES_PER_USER = 500
|
||||
const MAX_STUDY_NOTE_LENGTH = 12000
|
||||
const STUDY_SESSION_COOKIE = 'vbn_study_session'
|
||||
const STUDY_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
||||
let visitorStats = { ...EMPTY_VISITOR_STATS }
|
||||
let visitorStatsWritePromise = Promise.resolve()
|
||||
let contactSubmissions = []
|
||||
let contactSubmissionsWritePromise = Promise.resolve()
|
||||
let questions = []
|
||||
let questionsWritePromise = Promise.resolve()
|
||||
let studyUsers = []
|
||||
let studyUsersWritePromise = Promise.resolve()
|
||||
let studyNotesByUser = {}
|
||||
let studyNotesWritePromise = Promise.resolve()
|
||||
let downloadCounts = {}
|
||||
let downloadCountsWritePromise = Promise.resolve()
|
||||
let lastVisitorStatsWrite = { ok: true, at: null, error: null }
|
||||
@@ -394,6 +405,7 @@ let lastHitStatsWrite = { ok: true, at: null, error: null }
|
||||
let lastBackupStatus = { ok: true, at: null, error: null, file: null }
|
||||
let lastCachePurgeStatus = { ok: true, at: null, error: null }
|
||||
let lastDeployHookStatus = { ok: true, at: null, error: null }
|
||||
const studySessions = new Map()
|
||||
|
||||
function sanitizeReplyTemplates(value) {
|
||||
if (!Array.isArray(value)) return [...DEFAULT_REPLY_TEMPLATES]
|
||||
@@ -1543,6 +1555,190 @@ function loadQuestionsFromDisk() {
|
||||
questions = []
|
||||
})
|
||||
}
|
||||
|
||||
function cookieFlags() {
|
||||
return process.env.NODE_ENV === 'production' ? '; Secure' : ''
|
||||
}
|
||||
|
||||
function normalizeStudyUsername(value) {
|
||||
if (typeof value !== 'string') return ''
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function isValidStudyUsername(value) {
|
||||
return /^[a-z0-9._-]{3,40}$/.test(value)
|
||||
}
|
||||
|
||||
function hashStudyPassword(password) {
|
||||
return createHash('sha256').update(`study-user:${String(password)}`).digest('hex')
|
||||
}
|
||||
|
||||
function sanitizeStudyUsers(value) {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
|
||||
for (const item of value) {
|
||||
const username = normalizeStudyUsername(item?.username)
|
||||
const passwordHash = typeof item?.passwordHash === 'string' ? item.passwordHash.trim() : ''
|
||||
if (!isValidStudyUsername(username) || !passwordHash || seen.has(username)) continue
|
||||
seen.add(username)
|
||||
out.push({
|
||||
id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
username,
|
||||
passwordHash,
|
||||
createdAt: typeof item?.createdAt === 'string' ? item.createdAt : new Date().toISOString(),
|
||||
updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : new Date().toISOString(),
|
||||
lastLoginAt: typeof item?.lastLoginAt === 'string' ? item.lastLoginAt : null,
|
||||
})
|
||||
}
|
||||
|
||||
return out.slice(0, MAX_STUDY_USERS)
|
||||
}
|
||||
|
||||
function sanitizeStudyNotes(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
const out = {}
|
||||
|
||||
for (const [userId, entries] of Object.entries(value)) {
|
||||
if (typeof userId !== 'string' || !userId.trim()) continue
|
||||
if (!entries || typeof entries !== 'object' || Array.isArray(entries)) continue
|
||||
|
||||
const userNotes = {}
|
||||
let count = 0
|
||||
for (const [sectionId, note] of Object.entries(entries)) {
|
||||
if (count >= MAX_STUDY_NOTES_PER_USER) break
|
||||
if (!/^[a-z0-9-]{1,80}$/i.test(sectionId)) continue
|
||||
if (typeof note !== 'string') continue
|
||||
const trimmed = note.trim().slice(0, MAX_STUDY_NOTE_LENGTH)
|
||||
if (!trimmed) continue
|
||||
userNotes[sectionId] = trimmed
|
||||
count += 1
|
||||
}
|
||||
|
||||
out[userId] = userNotes
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function queueStudyUsersWrite() {
|
||||
studyUsersWritePromise = studyUsersWritePromise
|
||||
.then(async () => {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(
|
||||
STUDY_USERS_FILE,
|
||||
JSON.stringify({ users: studyUsers, updatedAt: new Date().toISOString() }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[study-users] failed to write users:', err)
|
||||
})
|
||||
}
|
||||
|
||||
function queueStudyNotesWrite() {
|
||||
studyNotesWritePromise = studyNotesWritePromise
|
||||
.then(async () => {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(
|
||||
STUDY_NOTES_FILE,
|
||||
JSON.stringify({ notesByUser: studyNotesByUser, updatedAt: new Date().toISOString() }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[study-notes] failed to write notes:', err)
|
||||
})
|
||||
}
|
||||
|
||||
function loadStudyUsersFromDisk() {
|
||||
return readFile(STUDY_USERS_FILE, 'utf8')
|
||||
.then(raw => {
|
||||
const parsed = JSON.parse(raw)
|
||||
const source = Array.isArray(parsed) ? parsed : parsed?.users
|
||||
studyUsers = sanitizeStudyUsers(source)
|
||||
})
|
||||
.catch(() => {
|
||||
studyUsers = []
|
||||
})
|
||||
}
|
||||
|
||||
function loadStudyNotesFromDisk() {
|
||||
return readFile(STUDY_NOTES_FILE, 'utf8')
|
||||
.then(raw => {
|
||||
const parsed = JSON.parse(raw)
|
||||
const source = parsed && typeof parsed === 'object' ? parsed.notesByUser : {}
|
||||
studyNotesByUser = sanitizeStudyNotes(source)
|
||||
})
|
||||
.catch(() => {
|
||||
studyNotesByUser = {}
|
||||
})
|
||||
}
|
||||
|
||||
function findStudyUserByUsername(username) {
|
||||
return studyUsers.find(user => user.username === normalizeStudyUsername(username))
|
||||
}
|
||||
|
||||
function createStudySession(userId) {
|
||||
const token = randomUUID()
|
||||
studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS })
|
||||
return token
|
||||
}
|
||||
|
||||
function setStudySessionCookie(res, token) {
|
||||
res.append(
|
||||
'Set-Cookie',
|
||||
`${STUDY_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(STUDY_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
||||
)
|
||||
}
|
||||
|
||||
function clearStudySessionCookie(res) {
|
||||
res.append(
|
||||
'Set-Cookie',
|
||||
`${STUDY_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
||||
)
|
||||
}
|
||||
|
||||
function getStudyUserFromRequest(req) {
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
const token = cookies[STUDY_SESSION_COOKIE]
|
||||
if (!token) return null
|
||||
|
||||
const session = studySessions.get(token)
|
||||
if (!session || session.expiresAt <= Date.now()) {
|
||||
studySessions.delete(token)
|
||||
return null
|
||||
}
|
||||
|
||||
const user = studyUsers.find(item => item.id === session.userId)
|
||||
if (!user) {
|
||||
studySessions.delete(token)
|
||||
return null
|
||||
}
|
||||
|
||||
session.expiresAt = Date.now() + STUDY_SESSION_TTL_MS
|
||||
studySessions.set(token, session)
|
||||
return user
|
||||
}
|
||||
|
||||
function requireStudyAuth(req, res, next) {
|
||||
const user = getStudyUserFromRequest(req)
|
||||
if (!user) {
|
||||
res.status(401).json({ message: 'Please sign in to save notes.' })
|
||||
return
|
||||
}
|
||||
req.studyUser = user
|
||||
next()
|
||||
}
|
||||
|
||||
function normalizeLessonSectionId(value) {
|
||||
if (typeof value !== 'string') return ''
|
||||
const trimmed = value.trim().toLowerCase()
|
||||
return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : ''
|
||||
}
|
||||
|
||||
// Rate limiter: max 10 attempts per 15 minutes per IP on the login endpoint
|
||||
const loginRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
@@ -1553,6 +1749,146 @@ const loginRateLimiter = rateLimit({
|
||||
skipSuccessfulRequests: true,
|
||||
})
|
||||
|
||||
const studyAuthRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { message: 'Too many attempts. Please wait 15 minutes and try again.' },
|
||||
skipSuccessfulRequests: true,
|
||||
})
|
||||
|
||||
app.get('/api/study-auth/status', (req, res) => {
|
||||
const user = getStudyUserFromRequest(req)
|
||||
res.json({ authenticated: Boolean(user), username: user?.username ?? '' })
|
||||
})
|
||||
|
||||
app.post('/api/study-auth/signup', studyAuthRateLimiter, (req, res) => {
|
||||
const username = normalizeStudyUsername(req.body?.username)
|
||||
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
||||
|
||||
if (!isValidStudyUsername(username)) {
|
||||
res.status(400).json({ message: 'Username must be 3-40 characters (letters, numbers, dot, underscore, dash).' })
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof password !== 'string' || password.length < 8 || password.length > 200) {
|
||||
res.status(400).json({ message: 'Password must be 8-200 characters.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (findStudyUserByUsername(username)) {
|
||||
res.status(409).json({ message: 'That username is already in use.' })
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const user = {
|
||||
id: randomUUID(),
|
||||
username,
|
||||
passwordHash: hashStudyPassword(password),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastLoginAt: now,
|
||||
}
|
||||
|
||||
studyUsers.push(user)
|
||||
if (studyUsers.length > MAX_STUDY_USERS) {
|
||||
studyUsers = studyUsers.slice(studyUsers.length - MAX_STUDY_USERS)
|
||||
}
|
||||
queueStudyUsersWrite()
|
||||
|
||||
const sessionToken = createStudySession(user.id)
|
||||
setStudySessionCookie(res, sessionToken)
|
||||
res.json({ ok: true, username: user.username })
|
||||
})
|
||||
|
||||
app.post('/api/study-auth/login', studyAuthRateLimiter, (req, res) => {
|
||||
const username = normalizeStudyUsername(req.body?.username)
|
||||
const password = typeof req.body?.password === 'string' ? req.body.password : ''
|
||||
const user = findStudyUserByUsername(username)
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({ message: 'Invalid username or password.' })
|
||||
return
|
||||
}
|
||||
|
||||
const submittedHash = hashStudyPassword(password)
|
||||
const expectedHash = user.passwordHash
|
||||
const a = Buffer.from(submittedHash, 'utf8')
|
||||
const b = Buffer.from(expectedHash, 'utf8')
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
res.status(401).json({ message: 'Invalid username or password.' })
|
||||
return
|
||||
}
|
||||
|
||||
user.lastLoginAt = new Date().toISOString()
|
||||
user.updatedAt = user.lastLoginAt
|
||||
queueStudyUsersWrite()
|
||||
|
||||
const sessionToken = createStudySession(user.id)
|
||||
setStudySessionCookie(res, sessionToken)
|
||||
res.json({ ok: true, username: user.username })
|
||||
})
|
||||
|
||||
app.post('/api/study-auth/logout', (req, res) => {
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
const token = cookies[STUDY_SESSION_COOKIE]
|
||||
if (token) {
|
||||
studySessions.delete(token)
|
||||
}
|
||||
clearStudySessionCookie(res)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.get('/api/study-notes', requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const notes = studyNotesByUser[user.id] ?? {}
|
||||
res.json({ notes })
|
||||
})
|
||||
|
||||
app.get('/api/study-notes/:sectionId', requireStudyAuth, (req, res) => {
|
||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||
if (!sectionId) {
|
||||
res.status(400).json({ message: 'Invalid section id.' })
|
||||
return
|
||||
}
|
||||
|
||||
const user = req.studyUser
|
||||
const note = studyNotesByUser[user.id]?.[sectionId] ?? ''
|
||||
res.json({ note })
|
||||
})
|
||||
|
||||
app.put('/api/study-notes/:sectionId', requireStudyAuth, (req, res) => {
|
||||
const sectionId = normalizeLessonSectionId(req.params.sectionId)
|
||||
if (!sectionId) {
|
||||
res.status(400).json({ message: 'Invalid section id.' })
|
||||
return
|
||||
}
|
||||
|
||||
const user = req.studyUser
|
||||
const rawNote = typeof req.body?.note === 'string' ? req.body.note : ''
|
||||
const note = rawNote.trim().slice(0, MAX_STUDY_NOTE_LENGTH)
|
||||
|
||||
if (!studyNotesByUser[user.id]) {
|
||||
studyNotesByUser[user.id] = {}
|
||||
}
|
||||
|
||||
if (!note) {
|
||||
delete studyNotesByUser[user.id][sectionId]
|
||||
} else {
|
||||
const existingCount = Object.keys(studyNotesByUser[user.id]).length
|
||||
if (!studyNotesByUser[user.id][sectionId] && existingCount >= MAX_STUDY_NOTES_PER_USER) {
|
||||
res.status(400).json({ message: 'Notes limit reached for this account.' })
|
||||
return
|
||||
}
|
||||
studyNotesByUser[user.id][sectionId] = note
|
||||
}
|
||||
|
||||
queueStudyNotesWrite()
|
||||
res.json({ ok: true, note })
|
||||
})
|
||||
|
||||
app.get('/api/admin-auth/status', async (req, res) => {
|
||||
res.json({
|
||||
authenticated: isValidAdminSession(req),
|
||||
@@ -3058,6 +3394,8 @@ Promise.all([
|
||||
loadReplyHistoryFromDisk(),
|
||||
loadQuestionsFromDisk(),
|
||||
loadDraftQuestionsFromDisk(),
|
||||
loadStudyUsersFromDisk(),
|
||||
loadStudyNotesFromDisk(),
|
||||
loadDownloadCountsFromDisk(),
|
||||
refreshContentCaches(),
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user