526 lines
25 KiB
JavaScript
526 lines
25 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
|
|
}
|
|
|
|
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)
|
|
: [],
|
|
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) {
|
|
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'
|
|
)
|
|
}
|