Make homepage study card admin-editable with NEW tag controls

This commit is contained in:
nmemmert
2026-05-12 12:47:13 -04:00
parent 853e56bd91
commit affd903f40
13 changed files with 3701 additions and 18 deletions
+339 -1
View File
@@ -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(),
])