c0a79b9ed0
- Admin can set birthday (month/day) per student in Study Users panel - Summer birthdays (Jun–Aug) prompt admin to set an alternate school-year date - 🎂 badge on student card header during birthday week - Dismissible happy birthday banner shown to student on their study hub - Admin receives email notification on the celebration day, once per year Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
578 lines
27 KiB
JavaScript
578 lines
27 KiB
JavaScript
import { randomUUID } from 'node:crypto'
|
||
|
||
export const DEFAULT_REDIRECT_RULES = [
|
||
{
|
||
id: 'spotify',
|
||
path: '/spotify',
|
||
target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl',
|
||
statusCode: 301,
|
||
},
|
||
{
|
||
id: 'apple',
|
||
path: '/apple',
|
||
target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate',
|
||
statusCode: 301,
|
||
},
|
||
{
|
||
id: 'amazon',
|
||
path: '/amazon',
|
||
target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate',
|
||
statusCode: 301,
|
||
},
|
||
]
|
||
|
||
export const DEFAULT_SEO = {
|
||
title: 'Verse by Verse with Nate',
|
||
description: 'Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.',
|
||
ogTitle: 'Verse by Verse with Nate',
|
||
ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.',
|
||
ogImage: '/images/podcast-art.jpeg',
|
||
canonicalUrl: 'https://versebyversewithnate.us/',
|
||
robotsPolicy: 'index,follow',
|
||
sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms', '/about', '/contact', '/episodes', '/resources', '/study', '/study/titus', '/study/colossians'],
|
||
}
|
||
|
||
export const DEFAULT_LEGAL = {
|
||
privacyTitle: 'Privacy Policy',
|
||
privacyBody: [
|
||
'We respect your privacy and collect limited data to operate and improve this site.',
|
||
'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.',
|
||
'Contact form details are used only to respond to your message and ministry communication requests.',
|
||
],
|
||
termsTitle: 'Terms',
|
||
termsBody: [
|
||
'Content on this site is for informational and ministry purposes.',
|
||
'External links are provided for convenience and are subject to third-party policies.',
|
||
'By using this site, you agree to lawful use and respectful communication.',
|
||
],
|
||
}
|
||
|
||
export const DEFAULT_PODCAST_FEATURED_LINKS = []
|
||
export const DEFAULT_PUBLISH_STATE = {
|
||
draftUpdatedAt: null,
|
||
publishedAt: null,
|
||
}
|
||
|
||
export function escapeHtml(value) {
|
||
return String(value)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''')
|
||
}
|
||
|
||
export function splitName(fullName) {
|
||
const parts = String(fullName).trim().split(/\s+/).filter(Boolean)
|
||
return {
|
||
firstName: parts[0] ?? '',
|
||
lastName: parts.slice(1).join(' '),
|
||
}
|
||
}
|
||
|
||
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 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 normalizeSitemapPath(value) {
|
||
if (typeof value !== 'string') return ''
|
||
const trimmed = value.trim()
|
||
if (!trimmed) return ''
|
||
if (trimmed === '/') return '/'
|
||
return normalizeRedirectPath(trimmed)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// Returns true if the user's celebration date falls within the current Mon–Sun week.
|
||
// Uses birthdayAlternateMonth/Day if set (for summer birthday workarounds), otherwise
|
||
// falls back to birthdayMonth/Day. Checks both the current year and next year so
|
||
// year-wrap birthdays (e.g. Dec 30 checked in late December) work correctly.
|
||
export function isBirthdayThisWeek(user) {
|
||
const month = user.birthdayMonth
|
||
const day = user.birthdayDay
|
||
if (!month || !day) return false
|
||
|
||
const celebMonth = user.birthdayAlternateMonth ?? month
|
||
const celebDay = user.birthdayAlternateDay ?? day
|
||
|
||
const today = new Date()
|
||
const monday = new Date(today)
|
||
monday.setUTCHours(0, 0, 0, 0)
|
||
const dow = monday.getUTCDay()
|
||
monday.setUTCDate(monday.getUTCDate() - (dow === 0 ? 6 : dow - 1))
|
||
const sunday = new Date(monday)
|
||
sunday.setUTCDate(sunday.getUTCDate() + 6)
|
||
sunday.setUTCHours(23, 59, 59, 999)
|
||
|
||
const yr = today.getUTCFullYear()
|
||
for (const year of [yr, yr + 1]) {
|
||
const bday = new Date(Date.UTC(year, celebMonth - 1, celebDay))
|
||
if (bday >= monday && bday <= sunday) return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// June, July, August are considered summer months.
|
||
export function isSummerBirthday(month) {
|
||
return Number.isInteger(month) && month >= 6 && month <= 8
|
||
}
|
||
|
||
// Returns "YYYY-MM-DD" for the celebration date (alternate if set, else birthday)
|
||
// in the given year. Returns null if no birthday is set.
|
||
export function celebrationDateForYear(user, year) {
|
||
const month = user.birthdayMonth
|
||
const day = user.birthdayDay
|
||
if (!month || !day) return null
|
||
const m = user.birthdayAlternateMonth ?? month
|
||
const d = user.birthdayAlternateDay ?? day
|
||
return `${year}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||
}
|
||
|
||
export function sanitizeFeaturedLinks(value) {
|
||
const source = Array.isArray(value) ? value : []
|
||
return source
|
||
.filter(item => item && typeof item === 'object')
|
||
.map(item => {
|
||
const discussionQuestions = Array.isArray(item.discussionQuestions)
|
||
? item.discussionQuestions
|
||
.filter(question => typeof question === 'string')
|
||
.map(question => question.trim())
|
||
.filter(Boolean)
|
||
.slice(0, 30)
|
||
: []
|
||
|
||
return {
|
||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||
title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '',
|
||
episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '',
|
||
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
|
||
url: sanitizeUrl(item.url),
|
||
embedUrl: sanitizeUrl(item.embedUrl),
|
||
showNotes: typeof item.showNotes === 'string' ? item.showNotes.trim().slice(0, 10000) : '',
|
||
discussionQuestions,
|
||
}
|
||
})
|
||
.filter(
|
||
item =>
|
||
item.title ||
|
||
item.summary ||
|
||
item.url ||
|
||
item.embedUrl ||
|
||
item.showNotes ||
|
||
item.discussionQuestions.length > 0,
|
||
)
|
||
}
|
||
|
||
function sanitizeCustomLinks(value) {
|
||
const source = Array.isArray(value) ? value : []
|
||
return source
|
||
.filter(item => item && typeof item === 'object')
|
||
.map(item => {
|
||
const placement =
|
||
item?.placement === 'platforms'
|
||
|| item?.placement === 'footer'
|
||
|| item?.placement === 'resources'
|
||
|| item?.placement === 'otherSites'
|
||
|| item?.placement === 'externalSites'
|
||
? item.placement
|
||
: 'footer'
|
||
|
||
return {
|
||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||
label: typeof item.label === 'string' ? item.label.trim().slice(0, 140) : '',
|
||
url: sanitizeUrl(item.url),
|
||
imageUrl: sanitizeUrl(item.imageUrl),
|
||
description: typeof item.description === 'string' ? item.description.trim().slice(0, 4000) : '',
|
||
amazonUrl: sanitizeUrl(item.amazonUrl),
|
||
amazonLabel: typeof item.amazonLabel === 'string' ? item.amazonLabel.trim().slice(0, 120) : '',
|
||
tags: Array.isArray(item.tags)
|
||
? item.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean).slice(0, 20)
|
||
: [],
|
||
placement,
|
||
}
|
||
})
|
||
.filter(item => item.label && item.url)
|
||
}
|
||
|
||
function sanitizeCustomBlocks(value) {
|
||
const source = Array.isArray(value) ? value : []
|
||
return source
|
||
.filter(item => item && typeof item === 'object')
|
||
.map(item => ({
|
||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||
heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '',
|
||
body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '',
|
||
page: item?.page === 'homepage' || item?.page === 'start-here' || item?.page === 'episodes' || item?.page === 'downloads' || item?.page === 'about' || item?.page === 'contact' || item?.page === 'questions'
|
||
? item.page
|
||
: 'downloads',
|
||
}))
|
||
.filter(item => item.heading || item.body)
|
||
}
|
||
|
||
function sanitizeArchivedSeriesResourceLinks(value) {
|
||
const source = Array.isArray(value) ? value : []
|
||
return source
|
||
.filter(item => item && typeof item === 'object')
|
||
.map(item => ({
|
||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||
label: typeof item.label === 'string' ? item.label.trim().slice(0, 120) : '',
|
||
description: typeof item.description === 'string' ? item.description.trim().slice(0, 4000) : '',
|
||
url: sanitizeUrl(item.url),
|
||
amazonUrl: sanitizeUrl(item.amazonUrl),
|
||
amazonLabel: typeof item.amazonLabel === 'string' ? item.amazonLabel.trim().slice(0, 120) : '',
|
||
}))
|
||
.filter(item => item.label && item.url)
|
||
}
|
||
|
||
function sanitizeArchivedSeriesNotes(value) {
|
||
const source = Array.isArray(value) ? value : []
|
||
return source
|
||
.filter(item => item && typeof item === 'object')
|
||
.map(item => ({
|
||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||
heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '',
|
||
body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '',
|
||
}))
|
||
.filter(item => item.heading || item.body)
|
||
}
|
||
|
||
function sanitizeColossiansStudySections(value) {
|
||
function normalizeReleasedAt(value) {
|
||
if (typeof value !== 'string' || !value.trim()) return ''
|
||
const parsed = Date.parse(value.trim())
|
||
if (!Number.isFinite(parsed)) return ''
|
||
return new Date(parsed).toISOString().replace('.000Z', 'Z')
|
||
}
|
||
|
||
const source = Array.isArray(value) ? value : []
|
||
return source
|
||
.filter(item => item && typeof item === 'object')
|
||
.map(item => {
|
||
const releasedAt = normalizeReleasedAt(item.releasedAt)
|
||
return {
|
||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||
chapter: Number.isInteger(item.chapter) && item.chapter >= 1 && item.chapter <= 999 ? item.chapter : 1,
|
||
reference: typeof item.reference === 'string' ? item.reference.trim().slice(0, 40) : '',
|
||
title: typeof item.title === 'string' ? item.title.trim().slice(0, 160) : '',
|
||
audioEmbedUrl: sanitizeUrl(item.audioEmbedUrl),
|
||
passageText: typeof item.passageText === 'string' ? item.passageText.trim().slice(0, 12000) : '',
|
||
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
|
||
commentary: typeof item.commentary === 'string' ? item.commentary.trim().slice(0, 12000) : '',
|
||
greekNotes: Array.isArray(item.greekNotes)
|
||
? item.greekNotes.filter(note => typeof note === 'string').map(note => note.trim()).filter(Boolean).slice(0, 20)
|
||
: [],
|
||
studyQuestions: Array.isArray(item.studyQuestions)
|
||
? item.studyQuestions.filter(question => typeof question === 'string').map(question => question.trim()).filter(Boolean).slice(0, 20)
|
||
: [],
|
||
announcement: typeof item.announcement === 'string' ? item.announcement.trim().slice(0, 600) : '',
|
||
checkpointPrompt: typeof item.checkpointPrompt === 'string' ? item.checkpointPrompt.trim().slice(0, 600) : '',
|
||
checkpointQuestions: Array.isArray(item.checkpointQuestions)
|
||
? item.checkpointQuestions.filter(question => typeof question === 'string').map(question => question.trim()).filter(Boolean).slice(0, 20)
|
||
: [],
|
||
...(releasedAt ? { releasedAt } : {}),
|
||
}
|
||
})
|
||
.filter(item => item.title || item.summary || item.commentary || item.greekNotes.length > 0 || item.studyQuestions.length > 0 || item.passageText || item.checkpointPrompt || item.checkpointQuestions.length > 0)
|
||
}
|
||
|
||
function sanitizeStudies(value) {
|
||
const source = Array.isArray(value) ? value : []
|
||
return source
|
||
.filter(item => item && typeof item === 'object')
|
||
.map(item => ({
|
||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||
slug: typeof item.slug === 'string' ? item.slug.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') : '',
|
||
title: typeof item.title === 'string' ? item.title.trim().slice(0, 160) : '',
|
||
description: typeof item.description === 'string' ? item.description.trim().slice(0, 800) : '',
|
||
homepageEyebrow: typeof item.homepageEyebrow === 'string' ? item.homepageEyebrow.trim().slice(0, 80) : '',
|
||
showOnHomepage: item.showOnHomepage === true,
|
||
showNewTag: item.showNewTag === true,
|
||
newTagLabel: typeof item.newTagLabel === 'string' ? item.newTagLabel.trim().slice(0, 24) : '',
|
||
status: item.status === 'planned' ? 'planned' : 'active',
|
||
difficulty: item.difficulty === 'advanced' ? 'advanced' : (item.difficulty === 'intermediate' ? 'intermediate' : 'beginner'),
|
||
estimatedHours: Number.isFinite(Number(item.estimatedHours)) ? Math.max(1, Math.min(500, Number(item.estimatedHours))) : 8,
|
||
completionBadge: typeof item.completionBadge === 'string' ? item.completionBadge.trim().slice(0, 120) : '',
|
||
numberOfChapters: Number.isInteger(item.numberOfChapters) && item.numberOfChapters >= 1 && item.numberOfChapters <= 999 ? item.numberOfChapters : 1,
|
||
sections: sanitizeColossiansStudySections(item.sections),
|
||
}))
|
||
.filter(item => item.slug && item.title)
|
||
}
|
||
|
||
function sanitizeArchivedSeries(value) {
|
||
const source = Array.isArray(value) ? value : []
|
||
return source
|
||
.filter(item => item && typeof item === 'object')
|
||
.map(item => ({
|
||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||
label: typeof item.label === 'string' ? item.label.trim().slice(0, 140) : '',
|
||
title: typeof item.title === 'string' ? item.title.trim().slice(0, 200) : '',
|
||
description: typeof item.description === 'string' ? item.description.trim().slice(0, 1000) : '',
|
||
imageUrl: sanitizeUrl(item.imageUrl),
|
||
listenUrl: sanitizeUrl(item.listenUrl),
|
||
studyGuideTitle: typeof item.studyGuideTitle === 'string' ? item.studyGuideTitle.trim().slice(0, 140) : '',
|
||
studyGuideDescription: typeof item.studyGuideDescription === 'string' ? item.studyGuideDescription.trim().slice(0, 4000) : '',
|
||
studyGuideUrl: sanitizeUrl(item.studyGuideUrl),
|
||
resourceLinks: sanitizeArchivedSeriesResourceLinks(item.resourceLinks),
|
||
notes: sanitizeArchivedSeriesNotes(item.notes),
|
||
episodeRange:
|
||
Number.isInteger(item?.episodeRange?.from)
|
||
&& Number.isInteger(item?.episodeRange?.to)
|
||
&& item.episodeRange.from > 0
|
||
&& item.episodeRange.to >= item.episodeRange.from
|
||
? { from: item.episodeRange.from, to: item.episodeRange.to }
|
||
: undefined,
|
||
}))
|
||
.filter(
|
||
item =>
|
||
item.title ||
|
||
item.description ||
|
||
item.resourceLinks.length > 0 ||
|
||
item.notes.length > 0,
|
||
)
|
||
}
|
||
|
||
export function sanitizeSiteContent(siteContent) {
|
||
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return {}
|
||
|
||
const seo = siteContent.seo && typeof siteContent.seo === 'object' ? siteContent.seo : {}
|
||
const legal = siteContent.legal && typeof siteContent.legal === 'object' ? siteContent.legal : {}
|
||
|
||
return {
|
||
...siteContent,
|
||
customLinks: sanitizeCustomLinks(siteContent.customLinks),
|
||
customBlocks: sanitizeCustomBlocks(siteContent.customBlocks),
|
||
archivedSeries: sanitizeArchivedSeries(siteContent.archivedSeries),
|
||
studies: sanitizeStudies(siteContent.studies),
|
||
colossiansStudySections: sanitizeColossiansStudySections(siteContent.colossiansStudySections),
|
||
redirects: sanitizeRedirectRules(siteContent.redirects),
|
||
podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS),
|
||
episodesSeoIntro: typeof siteContent.episodesSeoIntro === 'string' && siteContent.episodesSeoIntro.trim() ? siteContent.episodesSeoIntro.trim().slice(0, 600) : '',
|
||
welcomeEmailSpotifyBtnLabel: typeof siteContent.welcomeEmailSpotifyBtnLabel === 'string' ? siteContent.welcomeEmailSpotifyBtnLabel.trim().slice(0, 80) : '',
|
||
welcomeEmailAppleBtnLabel: typeof siteContent.welcomeEmailAppleBtnLabel === 'string' ? siteContent.welcomeEmailAppleBtnLabel.trim().slice(0, 80) : '',
|
||
welcomeEmailStartHereLinkLabel: typeof siteContent.welcomeEmailStartHereLinkLabel === 'string' ? siteContent.welcomeEmailStartHereLinkLabel.trim().slice(0, 80) : '',
|
||
studyWelcomeEmailSubject: typeof siteContent.studyWelcomeEmailSubject === 'string' ? siteContent.studyWelcomeEmailSubject.trim().slice(0, 200) : '',
|
||
studyWelcomeEmailBody: typeof siteContent.studyWelcomeEmailBody === 'string' ? siteContent.studyWelcomeEmailBody.trim().slice(0, 1000) : '',
|
||
studyWelcomeEmailCtaLabel: typeof siteContent.studyWelcomeEmailCtaLabel === 'string' ? siteContent.studyWelcomeEmailCtaLabel.trim().slice(0, 80) : '',
|
||
studyWelcomeEmailCtaPath: typeof siteContent.studyWelcomeEmailCtaPath === 'string' ? siteContent.studyWelcomeEmailCtaPath.trim().slice(0, 200) : '',
|
||
studyWelcomeEmailSignoff: typeof siteContent.studyWelcomeEmailSignoff === 'string' ? siteContent.studyWelcomeEmailSignoff.trim().slice(0, 200) : '',
|
||
studyDeletedEmailSubject: typeof siteContent.studyDeletedEmailSubject === 'string' ? siteContent.studyDeletedEmailSubject.trim().slice(0, 200) : '',
|
||
studyDeletedEmailBody: typeof siteContent.studyDeletedEmailBody === 'string' ? siteContent.studyDeletedEmailBody.trim().slice(0, 1000) : '',
|
||
studyDeletedEmailCtaLabel: typeof siteContent.studyDeletedEmailCtaLabel === 'string' ? siteContent.studyDeletedEmailCtaLabel.trim().slice(0, 80) : '',
|
||
studyDeletedEmailCtaPath: typeof siteContent.studyDeletedEmailCtaPath === 'string' ? siteContent.studyDeletedEmailCtaPath.trim().slice(0, 200) : '',
|
||
studyDeletedEmailSignoff: typeof siteContent.studyDeletedEmailSignoff === 'string' ? siteContent.studyDeletedEmailSignoff.trim().slice(0, 200) : '',
|
||
studyReminderEmailSubjectPrefix: typeof siteContent.studyReminderEmailSubjectPrefix === 'string' ? siteContent.studyReminderEmailSubjectPrefix.trim().slice(0, 200) : '',
|
||
studyReminderEmailBody: typeof siteContent.studyReminderEmailBody === 'string' ? siteContent.studyReminderEmailBody.trim().slice(0, 1000) : '',
|
||
studyReminderEmailCtaLabel: typeof siteContent.studyReminderEmailCtaLabel === 'string' ? siteContent.studyReminderEmailCtaLabel.trim().slice(0, 80) : '',
|
||
studyReminderEmailSignoff: typeof siteContent.studyReminderEmailSignoff === 'string' ? siteContent.studyReminderEmailSignoff.trim().slice(0, 200) : '',
|
||
emailChangeSubject: typeof siteContent.emailChangeSubject === 'string' ? siteContent.emailChangeSubject.trim().slice(0, 200) : '',
|
||
emailChangeBody: typeof siteContent.emailChangeBody === 'string' ? siteContent.emailChangeBody.trim().slice(0, 500) : '',
|
||
emailChangeCtaLabel: typeof siteContent.emailChangeCtaLabel === 'string' ? siteContent.emailChangeCtaLabel.trim().slice(0, 80) : '',
|
||
twoFaOtpEmailSubject: typeof siteContent.twoFaOtpEmailSubject === 'string' ? siteContent.twoFaOtpEmailSubject.trim().slice(0, 200) : '',
|
||
twoFaOtpEmailBody: typeof siteContent.twoFaOtpEmailBody === 'string' ? siteContent.twoFaOtpEmailBody.trim().slice(0, 500) : '',
|
||
twoFaOtpEmailExpiry: typeof siteContent.twoFaOtpEmailExpiry === 'string' ? siteContent.twoFaOtpEmailExpiry.trim().slice(0, 300) : '',
|
||
seo: {
|
||
title: typeof seo.title === 'string' && seo.title.trim() ? seo.title.trim().slice(0, 120) : DEFAULT_SEO.title,
|
||
description: typeof seo.description === 'string' && seo.description.trim() ? seo.description.trim().slice(0, 240) : DEFAULT_SEO.description,
|
||
ogTitle: typeof seo.ogTitle === 'string' && seo.ogTitle.trim() ? seo.ogTitle.trim().slice(0, 120) : DEFAULT_SEO.ogTitle,
|
||
ogDescription: typeof seo.ogDescription === 'string' && seo.ogDescription.trim() ? seo.ogDescription.trim().slice(0, 240) : DEFAULT_SEO.ogDescription,
|
||
ogImage: sanitizeUrl(seo.ogImage) || DEFAULT_SEO.ogImage,
|
||
canonicalUrl: sanitizeUrl(seo.canonicalUrl) || DEFAULT_SEO.canonicalUrl,
|
||
robotsPolicy: typeof seo.robotsPolicy === 'string' && seo.robotsPolicy.trim() ? seo.robotsPolicy.trim() : DEFAULT_SEO.robotsPolicy,
|
||
sitemapPaths: Array.isArray(seo.sitemapPaths)
|
||
? seo.sitemapPaths.map(pathItem => normalizeSitemapPath(pathItem)).filter(Boolean)
|
||
: [...DEFAULT_SEO.sitemapPaths],
|
||
},
|
||
legal: {
|
||
privacyTitle: typeof legal.privacyTitle === 'string' && legal.privacyTitle.trim() ? legal.privacyTitle.trim().slice(0, 120) : DEFAULT_LEGAL.privacyTitle,
|
||
privacyBody: Array.isArray(legal.privacyBody) && legal.privacyBody.length > 0
|
||
? legal.privacyBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20)
|
||
: [...DEFAULT_LEGAL.privacyBody],
|
||
termsTitle: typeof legal.termsTitle === 'string' && legal.termsTitle.trim() ? legal.termsTitle.trim().slice(0, 120) : DEFAULT_LEGAL.termsTitle,
|
||
termsBody: Array.isArray(legal.termsBody) && legal.termsBody.length > 0
|
||
? legal.termsBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20)
|
||
: [...DEFAULT_LEGAL.termsBody],
|
||
},
|
||
}
|
||
}
|
||
|
||
export function escapeXml(value) {
|
||
return String(value)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''')
|
||
}
|
||
|
||
export function buildAbsoluteUrl(baseUrl, maybeRelativePath) {
|
||
const safeBase = typeof baseUrl === 'string' && baseUrl.trim() ? baseUrl.trim() : DEFAULT_SEO.canonicalUrl
|
||
const root = safeBase.endsWith('/') ? safeBase.slice(0, -1) : safeBase
|
||
if (typeof maybeRelativePath !== 'string' || !maybeRelativePath.trim()) return root
|
||
const value = maybeRelativePath.trim()
|
||
if (/^https?:\/\//i.test(value)) return value
|
||
if (value.startsWith('/')) return `${root}${value}`
|
||
return `${root}/${value}`
|
||
}
|
||
|
||
export function injectSeoIntoHtml(html, siteContent) {
|
||
const seo = siteContent?.seo ?? DEFAULT_SEO
|
||
const title = seo.title || DEFAULT_SEO.title
|
||
const description = seo.description || DEFAULT_SEO.description
|
||
const ogTitle = seo.ogTitle || title
|
||
const ogDescription = seo.ogDescription || description
|
||
const canonical = buildAbsoluteUrl(seo.canonicalUrl || DEFAULT_SEO.canonicalUrl, '/')
|
||
const ogImage = buildAbsoluteUrl(canonical, seo.ogImage || DEFAULT_SEO.ogImage)
|
||
const robots = seo.robotsPolicy || DEFAULT_SEO.robotsPolicy
|
||
|
||
return html
|
||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${escapeHtml(title)}</title>`)
|
||
.replace(/<meta name="description" content="[^"]*"\s*\/?>/i, `<meta name="description" content="${escapeHtml(description)}" />`)
|
||
.replace(/<meta name="robots" content="[^"]*"\s*\/?>/i, `<meta name="robots" content="${escapeHtml(robots)}" />`)
|
||
.replace(/<meta property="og:title" content="[^"]*"\s*\/?>/i, `<meta property="og:title" content="${escapeHtml(ogTitle)}" />`)
|
||
.replace(/<meta property="og:description" content="[^"]*"\s*\/?>/i, `<meta property="og:description" content="${escapeHtml(ogDescription)}" />`)
|
||
.replace(/<meta property="og:image" content="[^"]*"\s*\/?>/i, `<meta property="og:image" content="${escapeHtml(ogImage)}" />`)
|
||
.replace(/<meta property="og:image:secure_url" content="[^"]*"\s*\/?>/i, `<meta property="og:image:secure_url" content="${escapeHtml(ogImage)}" />`)
|
||
.replace(/<meta property="og:url" content="[^"]*"\s*\/?>/i, `<meta property="og:url" content="${escapeHtml(canonical)}" />`)
|
||
.replace(/<link rel="canonical" href="[^"]*"\s*\/?>/i, `<link rel="canonical" href="${escapeHtml(canonical)}" />`)
|
||
}
|
||
|
||
export function normalizeAssetBaseName(name) {
|
||
if (typeof name !== 'string') return `upload-${Date.now()}`
|
||
const cleaned = name
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9._-]+/g, '-')
|
||
.replace(/-+/g, '-')
|
||
.replace(/^-|-$/g, '')
|
||
return cleaned || `upload-${Date.now()}`
|
||
}
|
||
|
||
export function inferImageExtensionFromDataUrl(dataUrl) {
|
||
if (typeof dataUrl !== 'string') return null
|
||
if (dataUrl.startsWith('data:image/png;base64,')) return '.png'
|
||
if (dataUrl.startsWith('data:image/jpeg;base64,')) return '.jpg'
|
||
if (dataUrl.startsWith('data:image/webp;base64,')) return '.webp'
|
||
if (dataUrl.startsWith('data:image/gif;base64,')) return '.gif'
|
||
if (dataUrl.startsWith('data:application/pdf;base64,')) return '.pdf'
|
||
if (dataUrl.startsWith('data:application/msword;base64,')) return '.doc'
|
||
if (dataUrl.startsWith('data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,')) return '.docx'
|
||
return 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 getClientIp(req) {
|
||
// Use req.ip: Express derives this from x-forwarded-for according to the
|
||
// configured trust proxy hop count, preventing header spoofing by clients.
|
||
return normalizeIp(req.ip)
|
||
}
|
||
|
||
export function parseCookies(cookieHeader) {
|
||
if (!cookieHeader) return {}
|
||
|
||
return cookieHeader
|
||
.split(';')
|
||
.map(v => v.trim())
|
||
.filter(Boolean)
|
||
.reduce((acc, part) => {
|
||
const idx = part.indexOf('=')
|
||
if (idx === -1) return acc
|
||
const key = part.slice(0, idx).trim()
|
||
const value = part.slice(idx + 1).trim()
|
||
try {
|
||
acc[key] = decodeURIComponent(value)
|
||
} catch {
|
||
acc[key] = value
|
||
}
|
||
return acc
|
||
}, {})
|
||
}
|
||
|
||
export function hasVisitorConsent(req) {
|
||
const cookies = parseCookies(req.headers.cookie)
|
||
return cookies['vbn_analytics_consent'] === 'yes'
|
||
}
|
||
|
||
// Secure cookies are required in production unless explicitly disabled with
|
||
// ALLOW_INSECURE_COOKIES=true — needed when the app is reached over plain
|
||
// HTTP (e.g. by LAN/VPN IP during a server migration, before TLS is set up),
|
||
// because browsers silently drop Secure cookies on http:// origins.
|
||
export function cookieSecureFlag() {
|
||
if (process.env.ALLOW_INSECURE_COOKIES === 'true') return ''
|
||
return process.env.NODE_ENV === 'production' ? '; Secure' : ''
|
||
}
|
||
|
||
export function setConsentCookie(res, consent) {
|
||
const value = consent ? 'yes' : 'no'
|
||
res.append('Set-Cookie', `vbn_analytics_consent=${value}; Max-Age=31536000; Path=/; SameSite=Lax${cookieSecureFlag()}`)
|
||
}
|
||
|
||
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'
|
||
)
|
||
}
|