Publish Container / build (push) Has been cancelled
- Community posts, replies, likes and lesson comments are refused (403) for finished and planned studies - Reading a closed study's community/comments returns an empty list with closed:true - Discussion hub lists only active studies; community page shows a closed notice - Lesson page hides Lesson Discussion, 'Discuss this lesson' and 'Go to Community' for inactive studies; Community buttons hidden on study/dashboard pages - Tests for isStudyDiscussionOpen Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
914 lines
37 KiB
JavaScript
914 lines
37 KiB
JavaScript
import { createHash, createHmac, randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto'
|
|
import path from 'node:path'
|
|
import {
|
|
parseCookies,
|
|
cookieSecureFlag,
|
|
normalizeIp,
|
|
isPrivateOrLocalIp,
|
|
normalizeRedirectPath,
|
|
sanitizeUrl,
|
|
sanitizeRedirectRules,
|
|
} from './helpers.js'
|
|
export { normalizeIp, isPrivateOrLocalIp, normalizeRedirectPath, sanitizeUrl, sanitizeRedirectRules } from './helpers.js'
|
|
import {
|
|
STUDY_SESSION_COOKIE,
|
|
STUDY_SESSION_TTL_MS,
|
|
STUDY_TOTP_PENDING_TTL_MS,
|
|
EMAIL_OTP_TTL_MS,
|
|
EMAIL_OTP_MAX_ATTEMPTS,
|
|
CONTACT_EMAIL_COOLDOWN_MS,
|
|
DEFAULT_REDIRECT_RULES,
|
|
VISITOR_RETENTION_DAYS_DEFAULT,
|
|
DOWNLOAD_TOKEN_TTL_MS,
|
|
} from './config.js'
|
|
import { state } from './state.js'
|
|
import { queueStudyRemindersWrite, queueStudyUsersWrite, queueStudySessionsWrite, loadUserProgress } from './data.js'
|
|
|
|
// ── Username / slug normalizers ────────────────────────────────────────────
|
|
|
|
export function normalizeStudyUsername(value) {
|
|
if (typeof value !== 'string') return ''
|
|
return value.trim().toLowerCase()
|
|
}
|
|
|
|
export function normalizeStudySlug(value) {
|
|
if (typeof value !== 'string') return ''
|
|
const trimmed = value.trim().toLowerCase()
|
|
return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : ''
|
|
}
|
|
|
|
export function isValidStudyUsername(value) {
|
|
return /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(value) && value.length <= 254
|
|
}
|
|
|
|
export function normalizeLessonSectionId(value) {
|
|
if (typeof value !== 'string') return ''
|
|
const trimmed = value.trim().toLowerCase()
|
|
return /^[a-z0-9-]{1,80}$/.test(trimmed) ? trimmed : ''
|
|
}
|
|
|
|
export function getStudySlugFromNoteId(sectionId) {
|
|
if (typeof sectionId !== 'string') return ''
|
|
const separatorIndex = sectionId.indexOf('--')
|
|
if (separatorIndex <= 0) return ''
|
|
return normalizeStudySlug(sectionId.slice(0, separatorIndex))
|
|
}
|
|
|
|
// ── Password / token hashing ───────────────────────────────────────────────
|
|
|
|
const LEGACY_HASH_RE = /^[0-9a-f]{64}$/
|
|
|
|
function legacyHashStudyPassword(password) {
|
|
return createHash('sha256').update(`study-user:${String(password)}`).digest('hex')
|
|
}
|
|
|
|
// New hashes are `scrypt$<salt hex>$<hash hex>`. Legacy accounts hold an
|
|
// unsalted SHA-256 hex digest; verifyStudyPassword accepts both and
|
|
// studyPasswordNeedsRehash lets login upgrade them transparently.
|
|
export function hashStudyPassword(password) {
|
|
const salt = randomBytes(16)
|
|
const hash = scryptSync(String(password), salt, 64)
|
|
return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`
|
|
}
|
|
|
|
export function verifyStudyPassword(password, stored) {
|
|
if (typeof stored !== 'string' || !stored) return false
|
|
let expected
|
|
let actual
|
|
if (stored.startsWith('scrypt$')) {
|
|
const [, saltHex, hashHex] = stored.split('$')
|
|
if (!saltHex || !hashHex) return false
|
|
expected = Buffer.from(hashHex, 'hex')
|
|
actual = scryptSync(String(password), Buffer.from(saltHex, 'hex'), expected.length)
|
|
} else if (LEGACY_HASH_RE.test(stored)) {
|
|
expected = Buffer.from(stored, 'hex')
|
|
actual = Buffer.from(legacyHashStudyPassword(password), 'hex')
|
|
} else {
|
|
return false
|
|
}
|
|
return expected.length === actual.length && timingSafeEqual(expected, actual)
|
|
}
|
|
|
|
export function studyPasswordNeedsRehash(stored) {
|
|
return typeof stored === 'string' && !stored.startsWith('scrypt$')
|
|
}
|
|
|
|
export function hashEmailChangeToken(token) {
|
|
return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex')
|
|
}
|
|
|
|
export function hashPasswordResetToken(token) {
|
|
return createHash('sha256').update(`study-password-reset:${String(token)}`).digest('hex')
|
|
}
|
|
|
|
export function hashEmailVerifyToken(token) {
|
|
return createHash('sha256').update(`study-email-verify:${String(token)}`).digest('hex')
|
|
}
|
|
|
|
// ── Catalog / enrollment helpers ───────────────────────────────────────────
|
|
|
|
// A study is 'complete' when an admin marks it so, or when it has lessons and every
|
|
// one of them has already been released. Planned studies never auto-complete.
|
|
export function getEffectiveStudyStatus(study) {
|
|
if (!study || typeof study !== 'object') return 'active'
|
|
if (study.status === 'planned') return 'planned'
|
|
if (study.status === 'complete') return 'complete'
|
|
const sections = Array.isArray(study.sections) ? study.sections : []
|
|
if (sections.length > 0 && sections.every(isSectionReleased)) return 'complete'
|
|
return 'active'
|
|
}
|
|
|
|
export function getStudyCatalog() {
|
|
const fallback = [
|
|
{ slug: 'colossians', title: 'Colossians: Rooted in Christ', status: 'active' },
|
|
]
|
|
const content = state.cachedSiteContent
|
|
if (!content || typeof content !== 'object') return fallback
|
|
|
|
if (Array.isArray(content.studies) && content.studies.length > 0) {
|
|
const out = []
|
|
const seen = new Set()
|
|
for (const study of content.studies) {
|
|
const slug = normalizeStudySlug(study?.slug)
|
|
if (!slug || seen.has(slug)) continue
|
|
seen.add(slug)
|
|
out.push({
|
|
slug,
|
|
title: typeof study?.title === 'string' && study.title.trim() ? study.title.trim() : slug,
|
|
status: getEffectiveStudyStatus(study),
|
|
})
|
|
}
|
|
if (out.length > 0) return out
|
|
}
|
|
|
|
return fallback
|
|
}
|
|
|
|
export function isEnrollableStudySlug(studySlug) {
|
|
const normalized = normalizeStudySlug(studySlug)
|
|
if (!normalized) return false
|
|
return getStudyCatalog().some(study => study.slug === normalized)
|
|
}
|
|
|
|
export function getStudyTitleBySlug(studySlug) {
|
|
const normalized = normalizeStudySlug(studySlug)
|
|
if (!normalized) return ''
|
|
const study = getStudyCatalog().find(item => item.slug === normalized)
|
|
return study?.title ?? ''
|
|
}
|
|
|
|
export function isStudyUserEnrolled(user, studySlug) {
|
|
const normalized = normalizeStudySlug(studySlug)
|
|
if (!normalized || !user) return false
|
|
return Array.isArray(user.enrolledStudySlugs) && user.enrolledStudySlugs.includes(normalized)
|
|
}
|
|
|
|
// A student who has finished every lesson in a study keeps access to it (lessons, notes,
|
|
// certificate) even if they later leave the enrollment. Completion is stored on the user
|
|
// so it survives content edits and un-enrolling.
|
|
export function isStudyUserCompleted(user, studySlug) {
|
|
const normalized = normalizeStudySlug(studySlug)
|
|
if (!normalized || !user) return false
|
|
return Array.isArray(user.completedStudySlugs) && user.completedStudySlugs.includes(normalized)
|
|
}
|
|
|
|
export function hasStudyAccess(user, studySlug) {
|
|
return isStudyUserEnrolled(user, studySlug) || isStudyUserCompleted(user, studySlug)
|
|
}
|
|
|
|
// Enrolled studies plus finished ones, deduplicated. Sent to the client as the list of
|
|
// studies the student can open.
|
|
export function getAccessibleStudySlugs(user) {
|
|
const enrolled = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
|
const completed = Array.isArray(user?.completedStudySlugs) ? user.completedStudySlugs : []
|
|
return [...new Set([...enrolled, ...completed])]
|
|
}
|
|
|
|
// Every lesson (released or not) is required, matching the certificate rules.
|
|
export function getRequiredSectionIds(studySlug) {
|
|
const content = state.cachedSiteContent
|
|
if (!content || !Array.isArray(content.studies)) return null
|
|
const study = content.studies.find(item => item?.slug === studySlug)
|
|
if (!study || !Array.isArray(study.sections)) return null
|
|
return study.sections.filter(item => item?.id).map(item => item.id)
|
|
}
|
|
|
|
// Marks the study completed on the user when every required lesson is done. Sticky: never
|
|
// removed, so a later new lesson doesn't revoke access. Returns true if newly completed.
|
|
export function markStudyCompletedIfDone(user, studySlug, completedSectionIds) {
|
|
const normalized = normalizeStudySlug(studySlug)
|
|
if (!normalized || isStudyUserCompleted(user, normalized)) return false
|
|
const required = getRequiredSectionIds(normalized)
|
|
if (!required || required.length === 0) return false
|
|
if (!required.every(id => completedSectionIds.includes(id))) return false
|
|
user.completedStudySlugs = [...(Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : []), normalized]
|
|
user.updatedAt = new Date().toISOString()
|
|
queueStudyUsersWrite()
|
|
return true
|
|
}
|
|
|
|
// One-off pass at startup so students who finished a study before this existed get the flag.
|
|
export async function backfillCompletedStudies() {
|
|
if (!state.cachedSiteContent) return 0
|
|
let updated = 0
|
|
for (const user of state.studyUsers) {
|
|
const progress = await loadUserProgress(user.id)
|
|
// Enrolled studies plus any with saved progress, so students who already left are caught too
|
|
const slugs = new Set([
|
|
...(Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []),
|
|
...Object.keys(progress.byStudy ?? {}),
|
|
])
|
|
for (const slug of slugs) {
|
|
if (markStudyCompletedIfDone(user, slug, progress.byStudy[slug]?.completedSectionIds ?? [])) updated++
|
|
}
|
|
}
|
|
return updated
|
|
}
|
|
|
|
// Discussion (community posts, replies, likes and lesson comments) is only open while a
|
|
// study is active. Finished and planned studies are closed.
|
|
export function isStudyDiscussionOpen(studySlug) {
|
|
const normalized = normalizeStudySlug(studySlug)
|
|
const studies = state.cachedSiteContent?.studies
|
|
if (!normalized || !Array.isArray(studies)) return false
|
|
const study = studies.find(item => normalizeStudySlug(item?.slug) === normalized)
|
|
return Boolean(study) && getEffectiveStudyStatus(study) === 'active'
|
|
}
|
|
|
|
export const DISCUSSION_CLOSED_MESSAGE = 'Discussion is only open while a study is active.'
|
|
|
|
export function findStudyUserById(userId) {
|
|
if (typeof userId !== 'string' || !userId.trim()) return undefined
|
|
return state.studyUsers.find(user => user.id === userId)
|
|
}
|
|
|
|
export function findStudyUserByUsername(username) {
|
|
return state.studyUsers.find(user => user.username === normalizeStudyUsername(username))
|
|
}
|
|
|
|
// ── Community display name ──────────────────────────────────────────────────
|
|
|
|
// Never derived from the student's email — falls back to a stable, anonymous
|
|
// handle assigned from their account id so nobody's address leaks into posts.
|
|
export function getCommunityDisplayName(user) {
|
|
const displayName = typeof user?.displayName === 'string' ? user.displayName.trim() : ''
|
|
if (displayName) return displayName
|
|
const id = typeof user?.id === 'string' ? user.id : ''
|
|
return `Student ${(id.replace(/-/g, '').slice(0, 4) || '0000').toUpperCase()}`
|
|
}
|
|
|
|
// ── Avatar ─────────────────────────────────────────────────────────────────
|
|
|
|
export function getStudyAvatarUrl(subject) {
|
|
let customAvatar = ''
|
|
let username = ''
|
|
|
|
if (subject && typeof subject === 'object') {
|
|
customAvatar = typeof subject.avatarUrl === 'string' ? subject.avatarUrl.trim() : ''
|
|
username = normalizeStudyUsername(subject.username)
|
|
} else if (typeof subject === 'string') {
|
|
username = normalizeStudyUsername(subject)
|
|
}
|
|
|
|
if (customAvatar) return customAvatar
|
|
if (!username) return ''
|
|
const hash = createHash('md5').update(username).digest('hex')
|
|
return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=96`
|
|
}
|
|
|
|
// ── Session management ─────────────────────────────────────────────────────
|
|
|
|
export function createStudySession(userId) {
|
|
const token = randomUUID()
|
|
state.studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS })
|
|
queueStudySessionsWrite()
|
|
return token
|
|
}
|
|
|
|
// Revokes every active session for a user (password change/reset, account deletion, "sign out everywhere").
|
|
export function invalidateAllStudySessions(userId, exceptToken = null) {
|
|
let changed = false
|
|
for (const [token, session] of state.studySessions) {
|
|
if (session.userId === userId && token !== exceptToken) {
|
|
state.studySessions.delete(token)
|
|
changed = true
|
|
}
|
|
}
|
|
if (changed) queueStudySessionsWrite()
|
|
}
|
|
|
|
export 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${cookieSecureFlag()}`,
|
|
)
|
|
}
|
|
|
|
export function clearStudySessionCookie(res) {
|
|
res.append(
|
|
'Set-Cookie',
|
|
`${STUDY_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
|
|
)
|
|
}
|
|
|
|
export function getStudyUserFromRequest(req) {
|
|
const cookies = parseCookies(req.headers.cookie)
|
|
const token = cookies[STUDY_SESSION_COOKIE]
|
|
if (!token) return null
|
|
|
|
const session = state.studySessions.get(token)
|
|
if (!session || session.expiresAt <= Date.now()) {
|
|
state.studySessions.delete(token)
|
|
return null
|
|
}
|
|
|
|
const user = state.studyUsers.find(item => item.id === session.userId)
|
|
if (!user) {
|
|
state.studySessions.delete(token)
|
|
return null
|
|
}
|
|
|
|
session.expiresAt = Date.now() + STUDY_SESSION_TTL_MS
|
|
state.studySessions.set(token, session)
|
|
return user
|
|
}
|
|
|
|
export 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()
|
|
}
|
|
|
|
// ── TOTP pending tokens ────────────────────────────────────────────────────
|
|
|
|
export function createStudyTotpPendingToken(userId) {
|
|
const token = randomUUID()
|
|
state.studyTotpPendingTokens.set(token, { userId, expiresAt: Date.now() + STUDY_TOTP_PENDING_TTL_MS })
|
|
return token
|
|
}
|
|
|
|
export function consumeStudyTotpPendingToken(token) {
|
|
const entry = state.studyTotpPendingTokens.get(token)
|
|
if (!entry) return null
|
|
state.studyTotpPendingTokens.delete(token)
|
|
if (Date.now() > entry.expiresAt) return null
|
|
return entry.userId
|
|
}
|
|
|
|
// ── Email OTP ──────────────────────────────────────────────────────────────
|
|
|
|
export function generateEmailOtp() {
|
|
return String(Math.floor(100000 + Math.random() * 900000))
|
|
}
|
|
|
|
function hashEmailOtp(code) {
|
|
return createHash('sha256').update(String(code).trim()).digest('hex')
|
|
}
|
|
|
|
export function storeEmailOtp(userId, code) {
|
|
state.emailOtpStore.set(userId, { codeHash: hashEmailOtp(code), expiresAt: Date.now() + EMAIL_OTP_TTL_MS, attempts: 0 })
|
|
}
|
|
|
|
export function verifyEmailOtp(userId, code) {
|
|
const entry = state.emailOtpStore.get(userId)
|
|
if (!entry) return 'no-code'
|
|
if (Date.now() > entry.expiresAt) { state.emailOtpStore.delete(userId); return 'expired' }
|
|
entry.attempts += 1
|
|
if (entry.attempts > EMAIL_OTP_MAX_ATTEMPTS) { state.emailOtpStore.delete(userId); return 'too-many' }
|
|
if (hashEmailOtp(String(code).trim()) !== entry.codeHash) return 'wrong'
|
|
state.emailOtpStore.delete(userId)
|
|
return 'ok'
|
|
}
|
|
|
|
// ── Titus download tokens ──────────────────────────────────────────────────
|
|
|
|
export function createTitusDownloadToken(email) {
|
|
const token = randomUUID()
|
|
state.titusDownloadTokens.set(token, {
|
|
email,
|
|
expiresAt: Date.now() + DOWNLOAD_TOKEN_TTL_MS,
|
|
})
|
|
return token
|
|
}
|
|
|
|
export function consumeTitusDownloadToken(token) {
|
|
const entry = state.titusDownloadTokens.get(token)
|
|
if (!entry) return false
|
|
state.titusDownloadTokens.delete(token)
|
|
if (entry.expiresAt <= Date.now()) return false
|
|
return true
|
|
}
|
|
|
|
// ── Release date helpers ───────────────────────────────────────────────────
|
|
|
|
export function getSectionReleaseTime(section) {
|
|
if (!section || typeof section !== 'object') return Number.NaN
|
|
const candidateValues = [section.releasedAt, section.releaseDate, section.availableAt, section.publishAt]
|
|
for (const candidate of candidateValues) {
|
|
if (typeof candidate !== 'string' || !candidate.trim()) continue
|
|
const releaseTime = Date.parse(candidate)
|
|
if (Number.isFinite(releaseTime)) return releaseTime
|
|
}
|
|
return Number.NaN
|
|
}
|
|
|
|
export function getSectionReleaseDate(section) {
|
|
const releaseTime = getSectionReleaseTime(section)
|
|
if (!Number.isFinite(releaseTime)) return null
|
|
return new Date(releaseTime)
|
|
}
|
|
|
|
export function isSectionReleased(section) {
|
|
const releaseTime = getSectionReleaseTime(section)
|
|
if (!Number.isFinite(releaseTime)) return false
|
|
return releaseTime <= Date.now()
|
|
}
|
|
|
|
export function redactUnreleasedSection(section) {
|
|
if (!section || typeof section !== 'object') return section
|
|
// episodeNumber is an admin-only reference and is never sent to the public site
|
|
const { episodeNumber: _episodeNumber, ...publicSection } = section
|
|
if (isSectionReleased(section)) return publicSection
|
|
return {
|
|
...publicSection,
|
|
passageText: '',
|
|
commentary: '',
|
|
greekNotes: [],
|
|
studyQuestions: [],
|
|
audioEmbedUrl: '',
|
|
}
|
|
}
|
|
|
|
export function filterSiteContentByReleaseDate(siteContent) {
|
|
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return siteContent
|
|
|
|
const filteredStudies = Array.isArray(siteContent.studies)
|
|
? siteContent.studies.map(study => {
|
|
if (!study || typeof study !== 'object') return study
|
|
const sections = Array.isArray(study.sections) ? study.sections.map(redactUnreleasedSection) : []
|
|
return { ...study, sections }
|
|
})
|
|
: siteContent.studies
|
|
|
|
const filteredLegacySections = Array.isArray(siteContent.colossiansStudySections)
|
|
? siteContent.colossiansStudySections.map(redactUnreleasedSection)
|
|
: siteContent.colossiansStudySections
|
|
|
|
return {
|
|
...siteContent,
|
|
studies: filteredStudies,
|
|
colossiansStudySections: filteredLegacySections,
|
|
}
|
|
}
|
|
|
|
// ── Analytics helpers ──────────────────────────────────────────────────────
|
|
|
|
export function normalizeHitPath(pathname) {
|
|
if (!pathname || pathname === '') return '/'
|
|
if (pathname.length > 1 && pathname.endsWith('/')) {
|
|
return pathname.slice(0, -1)
|
|
}
|
|
return pathname
|
|
}
|
|
|
|
export function shouldCountHit(req) {
|
|
if (req.method !== 'GET') return false
|
|
if (req.path.startsWith('/api/')) return false
|
|
if (req.path === '/admin' || req.path.startsWith('/admin/')) return false
|
|
if (req.path === '/favicon.ico') return false
|
|
const hasFileExt = path.extname(req.path) !== ''
|
|
if (hasFileExt) return false
|
|
const accept = req.get('accept') ?? ''
|
|
return accept.includes('text/html') || accept === '*/*' || accept === ''
|
|
}
|
|
|
|
export function sanitizeUserAgent(userAgent) {
|
|
if (!userAgent || typeof userAgent !== 'string') return 'unknown'
|
|
return userAgent.trim().slice(0, 300) || 'unknown'
|
|
}
|
|
|
|
export function detectDevice(userAgent) {
|
|
if (!userAgent || typeof userAgent !== 'string') return 'unknown'
|
|
const ua = userAgent.toLowerCase()
|
|
if (/tablet|ipad|playbook|silk|(android(?!.*mobile))/.test(ua)) return 'tablet'
|
|
if (/mobile|iphone|ipod|android|blackberry|opera mini|opera mobi|iemobile|windows phone|palm|smartphone/.test(ua)) return 'mobile'
|
|
return 'desktop'
|
|
}
|
|
|
|
export function sanitizeReferrer(referrer) {
|
|
if (!referrer || typeof referrer !== 'string') return ''
|
|
try {
|
|
const parsed = new URL(referrer.trim())
|
|
return `${parsed.hostname}${parsed.pathname}`.slice(0, 200)
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
export function detectBot(userAgent) {
|
|
if (!userAgent || typeof userAgent !== 'string') {
|
|
return { isBot: true, reason: 'missing-user-agent' }
|
|
}
|
|
const ua = userAgent.toLowerCase()
|
|
if (/googlebot|bingbot|yandexbot|baiduspider|slurp|duckduckbot|sluplicate|googlebot-mobile/.test(ua)) {
|
|
return { isBot: true, reason: 'search-crawler' }
|
|
}
|
|
if (/facebookexternalhit|twitterbot|linkedinbot|pinterest|whatsapp|slack|discord|telegram|reddit|mastodon/.test(ua)) {
|
|
return { isBot: true, reason: 'social-crawler' }
|
|
}
|
|
if (/headless|phantomjs|puppeteer|playwright|selenium|nightmarebot|watir|webdriver|wdio|nightmare/.test(ua)) {
|
|
return { isBot: true, reason: 'headless-browser' }
|
|
}
|
|
if (/uptimerobot|pingdom|statuspage|pagerduty|sentry|datadog|grafana|prometheus|newrelic|appdynamics/.test(ua)) {
|
|
return { isBot: true, reason: 'monitoring-tool' }
|
|
}
|
|
if (/nmap|nikto|masscan|metasploit|nessus|openvas|qualys|burpsuite|zap|acunetix|sqlmap/.test(ua)) {
|
|
return { isBot: true, reason: 'security-scanner' }
|
|
}
|
|
if (/^(curl|wget|python|java|go|node|ruby|php|perl|lua|rust)[\/-]/.test(ua)) {
|
|
return { isBot: true, reason: 'http-client' }
|
|
}
|
|
if (/bot|crawler|spider|scraper|indexer|reader|fetcher|loader|agent|spyware|tracking|monitor/.test(ua)) {
|
|
// Generic keywords like "reader" or "agent" can appear as substrings of
|
|
// otherwise-normal browser UAs (e.g. some webviews, regional browsers).
|
|
// Any UA carrying a real rendering-engine or vendor token is treated as
|
|
// a genuine browser rather than a bot, even if it also matches a keyword
|
|
// above — this list should stay a superset of the previous one so it
|
|
// only ever reduces false positives, never re-admits an actual bot.
|
|
if (!/chrome|firefox|safari|opera|edge|msie|trident|gecko|webkit|khtml|presto|crios|fxios|ucbrowser|samsungbrowser/.test(ua)) {
|
|
return { isBot: true, reason: 'bot-keyword' }
|
|
}
|
|
}
|
|
return { isBot: false, reason: null }
|
|
}
|
|
|
|
export function buildTopLocations(list, key) {
|
|
const counts = {}
|
|
for (const row of list) {
|
|
const val = row?.[key] || 'Unknown'
|
|
counts[val] = (counts[val] ?? 0) + 1
|
|
}
|
|
return Object.entries(counts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 10)
|
|
.map(([name, hits]) => ({ name, hits }))
|
|
}
|
|
|
|
export function buildLastNDaysStats(days) {
|
|
const out = []
|
|
const today = new Date()
|
|
for (let i = days - 1; i >= 0; i -= 1) {
|
|
const d = new Date(today)
|
|
d.setDate(today.getDate() - i)
|
|
const dayKey = d.toISOString().slice(0, 10)
|
|
out.push({ day: dayKey, hits: state.hitStats.byDay[dayKey] ?? 0 })
|
|
}
|
|
return out
|
|
}
|
|
|
|
export function recordHit(pathname, isBot = false, botReason = null) {
|
|
const nowIso = new Date().toISOString()
|
|
const dayKey = nowIso.slice(0, 10)
|
|
const safePath = normalizeHitPath(pathname)
|
|
|
|
state.hitStats.totalHits += 1
|
|
state.hitStats.lastHitAt = nowIso
|
|
state.hitStats.firstHitAt = state.hitStats.firstHitAt ?? nowIso
|
|
|
|
if (isBot) {
|
|
state.hitStats.botHits += 1
|
|
state.hitStats.byPathBot[safePath] = (state.hitStats.byPathBot[safePath] ?? 0) + 1
|
|
state.hitStats.byDayBot[dayKey] = (state.hitStats.byDayBot[dayKey] ?? 0) + 1
|
|
if (botReason) {
|
|
state.hitStats.botReasons[botReason] = (state.hitStats.botReasons[botReason] ?? 0) + 1
|
|
}
|
|
} else {
|
|
state.hitStats.realHits += 1
|
|
state.hitStats.byPathReal[safePath] = (state.hitStats.byPathReal[safePath] ?? 0) + 1
|
|
state.hitStats.byDayReal[dayKey] = (state.hitStats.byDayReal[dayKey] ?? 0) + 1
|
|
}
|
|
|
|
state.hitStats.byPath[safePath] = (state.hitStats.byPath[safePath] ?? 0) + 1
|
|
state.hitStats.byDay[dayKey] = (state.hitStats.byDay[dayKey] ?? 0) + 1
|
|
}
|
|
|
|
export function pruneStatsByDays(daysRaw) {
|
|
const days = Number(daysRaw)
|
|
const retentionDays = Number.isFinite(days) && days > 0 ? Math.floor(days) : 180
|
|
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000
|
|
|
|
const keepRecent = state.visitorStats.recentVisits.filter(v => {
|
|
const ts = new Date(v.at).getTime()
|
|
return Number.isFinite(ts) && ts >= cutoff
|
|
})
|
|
|
|
const allowedVisitorIds = new Set(keepRecent.map(v => v.visitorId))
|
|
const nextVisitors = {}
|
|
for (const [id, data] of Object.entries(state.visitorStats.visitors)) {
|
|
const lastSeen = new Date(data.lastSeenAt ?? 0).getTime()
|
|
if (allowedVisitorIds.has(id) || (Number.isFinite(lastSeen) && lastSeen >= cutoff)) {
|
|
nextVisitors[id] = data
|
|
}
|
|
}
|
|
|
|
const nextByDay = {}
|
|
const nextByDayReal = {}
|
|
const nextByDayBot = {}
|
|
for (const [day, count] of Object.entries(state.hitStats.byDay)) {
|
|
const ts = new Date(`${day}T00:00:00.000Z`).getTime()
|
|
if (Number.isFinite(ts) && ts >= cutoff) {
|
|
nextByDay[day] = count
|
|
nextByDayReal[day] = state.hitStats.byDayReal?.[day] ?? 0
|
|
nextByDayBot[day] = state.hitStats.byDayBot?.[day] ?? 0
|
|
}
|
|
}
|
|
|
|
state.visitorStats.recentVisits = keepRecent
|
|
state.visitorStats.visitors = nextVisitors
|
|
state.visitorStats.uniqueVisitors = Object.keys(nextVisitors).length
|
|
state.visitorStats.totalVisits = keepRecent.length
|
|
state.visitorStats.returningVisits = keepRecent.filter(v => v.returningVisitor).length
|
|
state.visitorStats.firstVisitAt = keepRecent.length > 0 ? keepRecent[keepRecent.length - 1].at : null
|
|
state.visitorStats.lastVisitAt = keepRecent.length > 0 ? keepRecent[0].at : null
|
|
|
|
state.hitStats.byDay = nextByDay
|
|
state.hitStats.byDayReal = nextByDayReal
|
|
state.hitStats.byDayBot = nextByDayBot
|
|
|
|
return {
|
|
retentionDays,
|
|
remainingVisits: state.visitorStats.totalVisits,
|
|
remainingVisitors: state.visitorStats.uniqueVisitors,
|
|
}
|
|
}
|
|
|
|
// ── Contact / email tracking ───────────────────────────────────────────────
|
|
|
|
export function noteContactEmailCooldown(emailAddress) {
|
|
const normalized = String(emailAddress || '').trim().toLowerCase()
|
|
if (!normalized) return { ok: true, retryAfterMs: 0 }
|
|
|
|
const now = Date.now()
|
|
const lastAt = state.contactSubmitCooldownByEmail.get(normalized)
|
|
if (typeof lastAt === 'number' && now - lastAt < CONTACT_EMAIL_COOLDOWN_MS) {
|
|
return { ok: false, retryAfterMs: CONTACT_EMAIL_COOLDOWN_MS - (now - lastAt) }
|
|
}
|
|
|
|
state.contactSubmitCooldownByEmail.set(normalized, now)
|
|
|
|
if (state.contactSubmitCooldownByEmail.size > 8000) {
|
|
const cutoff = now - CONTACT_EMAIL_COOLDOWN_MS * 3
|
|
for (const [email, timestamp] of state.contactSubmitCooldownByEmail.entries()) {
|
|
if (timestamp < cutoff) state.contactSubmitCooldownByEmail.delete(email)
|
|
}
|
|
}
|
|
|
|
return { ok: true, retryAfterMs: 0 }
|
|
}
|
|
|
|
export function extractTagValue(tags, name) {
|
|
if (!Array.isArray(tags)) return ''
|
|
const target = String(name || '').trim().toLowerCase()
|
|
if (!target) return ''
|
|
for (const tag of tags) {
|
|
if (!tag || typeof tag !== 'object') continue
|
|
const key = typeof tag.name === 'string' ? tag.name.trim().toLowerCase() : ''
|
|
const value = typeof tag.value === 'string' ? tag.value.trim() : ''
|
|
if (key === target && value) return value
|
|
}
|
|
return ''
|
|
}
|
|
|
|
export function mapResendEventToStatus(eventType) {
|
|
const normalized = String(eventType || '').trim().toLowerCase()
|
|
if (!normalized) return 'updated'
|
|
if (normalized.includes('delivered')) return 'delivered'
|
|
if (normalized.includes('delivery_delayed') || normalized.includes('delivery delayed')) return 'delayed'
|
|
if (normalized.includes('bounce')) return 'bounced'
|
|
if (normalized.includes('complain')) return 'complained'
|
|
if (normalized.includes('click')) return 'clicked'
|
|
if (normalized.includes('open')) return 'opened'
|
|
if (normalized.includes('send')) return 'sent'
|
|
return 'updated'
|
|
}
|
|
|
|
export function extractResendMessageId(result) {
|
|
if (!result || typeof result !== 'object') return ''
|
|
if (typeof result.id === 'string' && result.id.trim()) return result.id.trim()
|
|
if (result.data && typeof result.data === 'object' && typeof result.data.id === 'string' && result.data.id.trim()) {
|
|
return result.data.id.trim()
|
|
}
|
|
return ''
|
|
}
|
|
|
|
// ── Study reminder scheduler ───────────────────────────────────────────────
|
|
|
|
export async function scheduleStudyReminders(sendStudyReminderEmail) {
|
|
if (!state.cachedSiteContent) return
|
|
const now = new Date()
|
|
|
|
for (const user of state.studyUsers) {
|
|
if (user.studyRemindersEnabled !== true) continue
|
|
if (user.reminderFrequency === 'weekly') continue
|
|
const pausedUntil = user.remindersPausedUntil ? new Date(user.remindersPausedUntil) : null
|
|
if (pausedUntil && !isNaN(pausedUntil.getTime()) && pausedUntil > now) continue
|
|
const email = user.username
|
|
const displayName = user.displayName || email
|
|
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
|
if (enrolledStudySlugs.length === 0) continue
|
|
|
|
const userSent = state.studyReminders.users[user.id] ?? {}
|
|
for (const studySlug of enrolledStudySlugs) {
|
|
if (isStudyUserCompleted(user, studySlug)) continue // finished studies don't need lesson reminders
|
|
const study = Array.isArray(state.cachedSiteContent.studies)
|
|
? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
|
|
: undefined
|
|
if (!study) continue
|
|
|
|
for (const section of study.sections ?? []) {
|
|
const sectionId = section.id
|
|
const releaseDate = getSectionReleaseDate(section)
|
|
if (!releaseDate) continue
|
|
// Lessons release at 1:00 AM; reminder emails go out 8 hours later, at 9:00 AM the same day.
|
|
const sendDate = new Date(releaseDate.getTime() + 8 * 60 * 60 * 1000)
|
|
if (sendDate > now) continue
|
|
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
|
|
if (sentForStudy.includes(sectionId)) continue
|
|
|
|
const hoursSinceSend = (now.getTime() - sendDate.getTime()) / (1000 * 60 * 60)
|
|
if (hoursSinceSend > 24) continue
|
|
|
|
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
|
|
const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
|
|
const sectionUrl = `${base}/study/${study.slug}/${section.id}`
|
|
try {
|
|
await sendStudyReminderEmail(email, displayName, study.title, section.title, section.reference, sectionUrl)
|
|
} catch (err) {
|
|
console.error(`[study-reminders] failed to send reminder to ${email} for ${studySlug}/${sectionId}:`, err)
|
|
continue
|
|
}
|
|
userSent[studySlug] = [...sentForStudy, sectionId]
|
|
state.studyReminders.users[user.id] = userSent
|
|
// Persist after each successful send so a later crash doesn't re-send
|
|
queueStudyRemindersWrite()
|
|
}
|
|
}
|
|
}
|
|
state.studyReminders.updatedAt = new Date().toISOString()
|
|
queueStudyRemindersWrite()
|
|
}
|
|
|
|
// ── Study re-engagement scheduler ─────────────────────────────────────────
|
|
|
|
function getReengagementTiers() {
|
|
const cfg = state.cachedSiteContent ?? {}
|
|
const raw = typeof cfg.studyReengagementDays === 'string' ? cfg.studyReengagementDays : '7,14,30'
|
|
const days = raw.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n > 0)
|
|
if (days.length === 0) return [{ key: 't1', days: 7 }, { key: 't2', days: 14 }, { key: 't3', days: 30 }]
|
|
return days.slice(0, 3).map((d, i) => ({ key: `t${i + 1}`, days: d }))
|
|
}
|
|
|
|
export function makeUnsubUrl(userId) {
|
|
const secret = process.env.RESEND_WEBHOOK_TOKEN ?? 'siteforge'
|
|
const token = createHmac('sha256', secret).update(userId).digest('hex')
|
|
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
|
|
const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
|
|
return `${base}/api/study-auth/unsubscribe-reminders?uid=${encodeURIComponent(userId)}&token=${token}`
|
|
}
|
|
|
|
export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
|
|
if (!state.cachedSiteContent) return
|
|
const now = new Date()
|
|
const nowMs = now.getTime()
|
|
const tiers = getReengagementTiers()
|
|
|
|
for (const user of state.studyUsers) {
|
|
if (user.studyRemindersEnabled !== true) continue
|
|
if (!user.lastLoginAt) continue
|
|
const pausedUntil = user.remindersPausedUntil ? new Date(user.remindersPausedUntil) : null
|
|
if (pausedUntil && !isNaN(pausedUntil.getTime()) && pausedUntil > now) continue
|
|
const lastLogin = new Date(user.lastLoginAt)
|
|
if (isNaN(lastLogin.getTime())) continue
|
|
const daysSinceLogin = (nowMs - lastLogin.getTime()) / (1000 * 60 * 60 * 24)
|
|
|
|
const sent = typeof user.reengagementSentAt === 'object' && user.reengagementSentAt !== null ? user.reengagementSentAt : {}
|
|
|
|
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
|
const studies = Array.isArray(state.cachedSiteContent.studies) ? state.cachedSiteContent.studies : []
|
|
// Studies the student already finished aren't "unfinished business" for a come-back nudge.
|
|
const enrolledStudies = enrolledStudySlugs
|
|
.filter(slug => !isStudyUserCompleted(user, slug))
|
|
.map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug))
|
|
.filter(Boolean)
|
|
|
|
if (enrolledStudies.length === 0) continue
|
|
|
|
const firstStudy = enrolledStudies[0]
|
|
const studyTitle = enrolledStudies.length === 1 ? firstStudy.title : null
|
|
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
|
|
const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
|
|
const studyUrl = enrolledStudies.length === 1
|
|
? `${base}/study/${firstStudy.slug}`
|
|
: `${base}/study`
|
|
|
|
for (const tier of tiers) {
|
|
if (daysSinceLogin < tier.days) continue
|
|
if (sent[tier.key]) continue
|
|
|
|
const unsubUrl = makeUnsubUrl(user.id)
|
|
try {
|
|
await sendStudyReengagementEmail(user.username, user.displayName || user.username, studyTitle, studyUrl, tier.key, unsubUrl)
|
|
} catch (err) {
|
|
console.error(`[study-reengagement] failed to send ${tier.key} to ${user.username}:`, err)
|
|
continue
|
|
}
|
|
user.reengagementSentAt = { ...sent, [tier.key]: new Date().toISOString() }
|
|
queueStudyUsersWrite()
|
|
break // one tier per run so we don't spam if they've been gone >30 days
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Weekly digest scheduler ───────────────────────────────────────────────
|
|
|
|
export async function scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail) {
|
|
if (!state.cachedSiteContent) return
|
|
const now = new Date()
|
|
const nowMs = now.getTime()
|
|
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
|
|
|
for (const user of state.studyUsers) {
|
|
if (user.reminderFrequency !== 'weekly') continue
|
|
if (user.studyRemindersEnabled !== true) continue
|
|
|
|
const pausedUntil = user.remindersPausedUntil ? new Date(user.remindersPausedUntil) : null
|
|
if (pausedUntil && !isNaN(pausedUntil.getTime()) && pausedUntil > now) continue
|
|
|
|
const lastDigestMs = user.weeklyDigestSentAt ? new Date(user.weeklyDigestSentAt).getTime() : 0
|
|
if (nowMs - lastDigestMs < ONE_WEEK_MS) continue
|
|
|
|
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
|
|
if (enrolledStudySlugs.length === 0) continue
|
|
|
|
const userSent = state.studyReminders.users[user.id] ?? {}
|
|
const lessons = []
|
|
const newlySentByStudy = {}
|
|
|
|
for (const studySlug of enrolledStudySlugs) {
|
|
if (isStudyUserCompleted(user, studySlug)) continue
|
|
const study = Array.isArray(state.cachedSiteContent.studies)
|
|
? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
|
|
: undefined
|
|
if (!study) continue
|
|
|
|
// Finished studies are self-paced: only mention lessons from the last week so a
|
|
// late enrollee isn't sent the whole back catalogue in one digest.
|
|
const isComplete = getEffectiveStudyStatus(study) === 'complete'
|
|
for (const section of study.sections ?? []) {
|
|
const releaseTime = getSectionReleaseTime(section)
|
|
if (!Number.isFinite(releaseTime) || releaseTime > nowMs) continue
|
|
if (isComplete && nowMs - releaseTime > ONE_WEEK_MS) continue
|
|
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
|
|
if (sentForStudy.includes(section.id)) continue
|
|
|
|
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
|
|
const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
|
|
lessons.push({
|
|
sectionId: section.id,
|
|
studySlug,
|
|
sectionTitle: section.title ?? section.id,
|
|
sectionReference: section.reference ?? '',
|
|
studyTitle: study.title ?? studySlug,
|
|
sectionUrl: `${base}/study/${study.slug}/${section.id}`,
|
|
})
|
|
if (!newlySentByStudy[studySlug]) newlySentByStudy[studySlug] = []
|
|
newlySentByStudy[studySlug].push(section.id)
|
|
}
|
|
}
|
|
|
|
if (lessons.length === 0) continue
|
|
|
|
const unsubUrl = makeUnsubUrl(user.id)
|
|
try {
|
|
await sendStudyWeeklyDigestEmail(user.username, user.displayName || user.username, lessons, unsubUrl)
|
|
} catch (err) {
|
|
console.error(`[study-digest] failed to send digest to ${user.username}:`, err)
|
|
continue
|
|
}
|
|
|
|
for (const [studySlug, sectionIds] of Object.entries(newlySentByStudy)) {
|
|
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
|
|
userSent[studySlug] = [...new Set([...sentForStudy, ...sectionIds])]
|
|
}
|
|
state.studyReminders.users[user.id] = userSent
|
|
queueStudyRemindersWrite()
|
|
|
|
user.weeklyDigestSentAt = now.toISOString()
|
|
queueStudyUsersWrite()
|
|
}
|
|
}
|