Security & hardening pass: scrypt password hashing, persisted admin sessions, upvote/analytics rate limits, dependency fixes, healthcheck, lint cleanup; v2.10.0
Publish Container / build (push) Successful in 17m3s
Publish Container / build (push) Successful in 17m3s
- Study passwords: salted scrypt; legacy SHA-256 hashes verified and upgraded on login - Admin sessions persisted (hashed tokens) so deploys no longer log the admin out - Upvotes: rate limit + one per client per question, approved questions only; rate limit on analytics play/event - npm audit: 46 -> 4 vulnerabilities (tiptap bumped to 3.31.4) - Untrack per-user data (study users/notes/progress/etc.) from git - Dockerfile HEALTHCHECK; node --test suite for password hashing - Fix email template cards remounting (lost input focus) on each keystroke - Lint: 34 errors -> 0 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e2150e1cd7
commit
8a796964b9
27 files changed
+880
-1034
No files matched your search
@@ -4,8 +4,21 @@ import { MAX_QUESTIONS } from '../config.js'
|
||||
import { state } from '../state.js'
|
||||
import { queueQuestionsWrite, queueDraftQuestionsWrite } from '../data.js'
|
||||
import { buildQuestionAnsweredEmailTemplate, getResendFromAddress, getResendReplyToAddress, sendResendEmailWithRetry, getCanonicalBaseUrl } from '../email.js'
|
||||
import { buildAbsoluteUrl } from '../helpers.js'
|
||||
import { buildAbsoluteUrl, getClientIp } from '../helpers.js'
|
||||
import { Resend } from 'resend'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
|
||||
const upvoteRateLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { message: 'Too many votes. Please try again later.' },
|
||||
})
|
||||
|
||||
// One upvote per client per question until the process restarts.
|
||||
const upvotedBy = new Set()
|
||||
const UPVOTED_BY_MAX = 50000
|
||||
|
||||
function ensureDraftQuestions() {
|
||||
if (state.draftQuestions !== null) return
|
||||
@@ -148,15 +161,22 @@ export function register(app) {
|
||||
})
|
||||
|
||||
// Anonymous upvote — no auth required, lightweight increment
|
||||
app.post('/api/questions/:id/upvote', (req, res) => {
|
||||
app.post('/api/questions/:id/upvote', upvoteRateLimiter, (req, res) => {
|
||||
const { id } = req.params
|
||||
// Look in live questions first, then draft
|
||||
const liveQ = state.questions.find(q => q.id === id && q.isApproved === true)
|
||||
const draftQ = state.draftQuestions ? state.draftQuestions.find(q => q.id === id) : null
|
||||
const question = liveQ ?? draftQ
|
||||
// Only approved questions are public, so only they can be upvoted.
|
||||
const source = state.draftQuestions ?? state.questions
|
||||
const question = source.find(q => q.id === id && q.isApproved === true)
|
||||
const liveQ = question && state.questions.includes(question) ? question : null
|
||||
const draftQ = question && state.draftQuestions?.includes(question) ? question : null
|
||||
if (!question) {
|
||||
res.status(404).json({ message: 'Question not found.' }); return
|
||||
}
|
||||
const voteKey = `${getClientIp(req)}|${id}`
|
||||
if (upvotedBy.has(voteKey)) {
|
||||
res.json({ ok: true, upvotes: question.upvotes ?? 0, duplicate: true }); return
|
||||
}
|
||||
if (upvotedBy.size >= UPVOTED_BY_MAX) upvotedBy.clear()
|
||||
upvotedBy.add(voteKey)
|
||||
question.upvotes = ((question.upvotes ?? 0) + 1)
|
||||
if (liveQ) queueQuestionsWrite()
|
||||
if (draftQ) queueDraftQuestionsWrite()
|
||||
|
||||
Reference in new issue
Block a user