refractor server.js
This commit is contained in:
@@ -0,0 +1,659 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { parseCookies } 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,
|
||||
} from './config.js'
|
||||
import { state } from './state.js'
|
||||
import { queueStudyRemindersWrite } 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 ───────────────────────────────────────────────
|
||||
|
||||
export function hashStudyPassword(password) {
|
||||
return createHash('sha256').update(`study-user:${String(password)}`).digest('hex')
|
||||
}
|
||||
|
||||
export function hashEmailChangeToken(token) {
|
||||
return createHash('sha256').update(`study-email-change:${String(token)}`).digest('hex')
|
||||
}
|
||||
|
||||
// ── Catalog / enrollment helpers ───────────────────────────────────────────
|
||||
|
||||
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: study?.status === 'planned' ? 'planned' : 'active',
|
||||
})
|
||||
}
|
||||
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 && study.status !== 'planned')
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// ── 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 cookieFlags() {
|
||||
return process.env.NODE_ENV === 'production' ? '; Secure' : ''
|
||||
}
|
||||
|
||||
export function createStudySession(userId) {
|
||||
const token = randomUUID()
|
||||
state.studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS })
|
||||
return token
|
||||
}
|
||||
|
||||
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${cookieFlags()}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function clearStudySessionCookie(res) {
|
||||
res.append(
|
||||
'Set-Cookie',
|
||||
`${STUDY_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
||||
)
|
||||
}
|
||||
|
||||
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() + (10 * 60 * 1000),
|
||||
})
|
||||
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
|
||||
if (isSectionReleased(section)) return section
|
||||
return {
|
||||
...section,
|
||||
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)) {
|
||||
if (!/chrome|firefox|safari|opera|edge|msie|trident|like gecko/.test(ua)) {
|
||||
return { isBot: true, reason: 'bot-keyword' }
|
||||
}
|
||||
}
|
||||
return { isBot: false, reason: null }
|
||||
}
|
||||
|
||||
export function normalizeIp(rawIp) {
|
||||
if (!rawIp) return 'unknown'
|
||||
let ip = String(rawIp).trim()
|
||||
if (ip.includes(',')) ip = ip.split(',')[0].trim()
|
||||
if (ip.startsWith('::ffff:')) ip = ip.slice(7)
|
||||
if (ip === '::1') ip = '127.0.0.1'
|
||||
return ip || 'unknown'
|
||||
}
|
||||
|
||||
export function isPrivateOrLocalIp(ip) {
|
||||
return (
|
||||
ip === '127.0.0.1'
|
||||
|| ip === 'localhost'
|
||||
|| ip.startsWith('10.')
|
||||
|| ip.startsWith('192.168.')
|
||||
|| /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip)
|
||||
|| ip.startsWith('fc')
|
||||
|| ip.startsWith('fd')
|
||||
|| ip.startsWith('fe80:')
|
||||
|| ip === 'unknown'
|
||||
)
|
||||
}
|
||||
|
||||
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 { VISITOR_RETENTION_DAYS_DEFAULT, EMPTY_HIT_STATS: _unused } = { VISITOR_RETENTION_DAYS_DEFAULT: 180 }
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Redirect / URL helpers ─────────────────────────────────────────────────
|
||||
|
||||
export function normalizeRedirectPath(value) {
|
||||
if (typeof value !== 'string') return ''
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
||||
const normalized = withSlash.replace(/\/+/g, '/')
|
||||
if (normalized === '/') return ''
|
||||
if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return ''
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function sanitizeUrl(value) {
|
||||
if (typeof value !== 'string') return ''
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.startsWith('/')) return trimmed
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed
|
||||
return ''
|
||||
}
|
||||
|
||||
export function sanitizeRedirectRules(value) {
|
||||
const source = Array.isArray(value) ? value : []
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
|
||||
for (const item of source) {
|
||||
const pathValue = normalizeRedirectPath(item?.path)
|
||||
const target = sanitizeUrl(item?.target)
|
||||
const statusCode = Number(item?.statusCode) === 302 ? 302 : 301
|
||||
if (!pathValue || !target) continue
|
||||
if (seen.has(pathValue)) continue
|
||||
seen.add(pathValue)
|
||||
out.push({
|
||||
id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
path: pathValue,
|
||||
target,
|
||||
statusCode,
|
||||
})
|
||||
}
|
||||
|
||||
return out.length > 0 ? out : DEFAULT_REDIRECT_RULES
|
||||
}
|
||||
|
||||
// ── 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 ''
|
||||
}
|
||||
|
||||
export function normalizeMessageType(value) {
|
||||
if (value === 'question' || value === 'testimony' || value === 'topic') return value
|
||||
return 'general'
|
||||
}
|
||||
|
||||
// ── 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
|
||||
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) {
|
||||
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
|
||||
if (releaseDate > now) continue
|
||||
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
|
||||
if (sentForStudy.includes(sectionId)) continue
|
||||
|
||||
const hoursSinceRelease = (now.getTime() - releaseDate.getTime()) / (1000 * 60 * 60)
|
||||
if (hoursSinceRelease > 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}`
|
||||
await sendStudyReminderEmail(email, displayName, study.title, section.title, section.reference, sectionUrl)
|
||||
userSent[studySlug] = [...sentForStudy, sectionId]
|
||||
state.studyReminders.users[user.id] = userSent
|
||||
}
|
||||
}
|
||||
}
|
||||
state.studyReminders.updatedAt = new Date().toISOString()
|
||||
queueStudyRemindersWrite()
|
||||
}
|
||||
Reference in New Issue
Block a user