413 lines
16 KiB
JavaScript
413 lines
16 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'],
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
: '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),
|
|
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) : '',
|
|
}))
|
|
.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) : '',
|
|
url: sanitizeUrl(item.url),
|
|
}))
|
|
.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 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),
|
|
}))
|
|
.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),
|
|
redirects: sanitizeRedirectRules(siteContent.redirects),
|
|
podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS),
|
|
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'
|
|
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) {
|
|
const forwarded = req.headers['x-forwarded-for']
|
|
if (forwarded) {
|
|
return normalizeIp(forwarded)
|
|
}
|
|
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'
|
|
}
|
|
|
|
export function setConsentCookie(res, consent) {
|
|
const value = consent ? 'yes' : 'no'
|
|
const secureFlag = process.env.NODE_ENV === 'production' ? '; Secure' : ''
|
|
res.append('Set-Cookie', `vbn_analytics_consent=${value}; Max-Age=31536000; Path=/; SameSite=Lax${secureFlag}`)
|
|
}
|
|
|
|
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'
|
|
)
|
|
}
|