17c9cbbc8b
- server/data.js: preserve source/htmlBody/inboundTo/messageId across server restarts (sanitizeLoadedContactSubmissions was silently dropping them on reload from disk) - cloudflare/email-worker.js: rewrite MIME parsing to split on the actual boundary marker instead of any literal "--", unfold multi-line headers, and correctly recombine multi-byte UTF-8 in quoted-printable decoding - server/routes/inbound-email.js: validate Message-ID against RFC 5322 grammar before storing/using it, and compare the webhook secret with timingSafeEqual to match the rest of the codebase's auth checks - server/routes/contact.js: re-validate messageId at the point it's injected into outgoing In-Reply-To/References headers; move the allowed reply-from addresses into a shared config constant - src/AdminPage.tsx: 30s inbox poll now syncs field updates (e.g. archived) on already-loaded submissions instead of only appending new ones; consolidate the duplicated from-address list - .claude/launch.json: add a vite dev server preview config used to verify these changes Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
230 lines
9.5 KiB
JavaScript
230 lines
9.5 KiB
JavaScript
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { validateAdminPasswordSetup } from './auth.js'
|
|
|
|
const __filename = fileURLToPath(import.meta.url)
|
|
const __dirname = path.dirname(__filename)
|
|
// server/config.js is inside server/, so the project root is one level up
|
|
export const ROOT_DIR = path.resolve(__dirname, '..')
|
|
|
|
const DEFAULT_DATA_DIR = path.join(ROOT_DIR, 'data')
|
|
const configuredDataDir = typeof process.env.SITEFORGE_DATA_DIR === 'string' ? process.env.SITEFORGE_DATA_DIR.trim() : ''
|
|
export const DATA_DIR = configuredDataDir
|
|
? (path.isAbsolute(configuredDataDir) ? configuredDataDir : path.resolve(ROOT_DIR, configuredDataDir))
|
|
: DEFAULT_DATA_DIR
|
|
|
|
export const DATA_FILE = path.join(DATA_DIR, 'admin-content.json')
|
|
export const DRAFT_DATA_FILE = path.join(DATA_DIR, 'admin-content-draft.json')
|
|
export const HIT_STATS_FILE = path.join(DATA_DIR, 'hit-stats.json')
|
|
export const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-stats.json')
|
|
export const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json')
|
|
export const QUESTIONS_FILE = path.join(DATA_DIR, 'questions.json')
|
|
export const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json')
|
|
export const STUDY_USERS_FILE = path.join(DATA_DIR, 'study-users.json')
|
|
export const STUDY_NOTES_FILE = path.join(DATA_DIR, 'study-notes.json') // legacy — kept only for one-time migration
|
|
export const STUDY_NOTES_DIR = path.join(DATA_DIR, 'study-notes')
|
|
export const STUDY_PROGRESS_DIR = path.join(DATA_DIR, 'study-progress')
|
|
export const STUDY_COMMUNITY_FILE = path.join(DATA_DIR, 'study-community.json')
|
|
export const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
|
|
export const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
|
|
export const PODCAST_CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json')
|
|
export const BACKUP_DIR = path.join(DATA_DIR, 'backups')
|
|
export const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
|
|
export const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
|
|
export const DOWNLOAD_COUNTS_FILE = path.join(DATA_DIR, 'download-counts.json')
|
|
export const STUDY_REMINDERS_FILE = path.join(DATA_DIR, 'study-reminders.json')
|
|
export const STUDY_COMMENTS_FILE = path.join(DATA_DIR, 'study-section-comments.json')
|
|
export const STUDY_CERTIFICATES_FILE = path.join(DATA_DIR, 'study-certificates.json')
|
|
export const EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.json')
|
|
export const QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.json')
|
|
export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json')
|
|
export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json')
|
|
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
|
|
|
export const DIST_DIR = path.join(ROOT_DIR, 'dist')
|
|
export const INDEX_FILE = path.join(DIST_DIR, 'index.html')
|
|
export const DIST_IMAGES_DIR = path.join(DIST_DIR, 'images')
|
|
export const PUBLIC_IMAGES_DIR = path.join(ROOT_DIR, 'public', 'images')
|
|
|
|
validateAdminPasswordSetup()
|
|
|
|
export const TITUS_STUDY_FILE = process.env.TITUS_STUDY_FILE
|
|
? path.resolve(ROOT_DIR, process.env.TITUS_STUDY_FILE)
|
|
: path.join(ROOT_DIR, 'A_Study_of_Titus.pdf')
|
|
export const TITUS_STUDY_DOWNLOAD_NAME = process.env.TITUS_STUDY_DOWNLOAD_NAME ?? 'A_Study_of_Titus.pdf'
|
|
|
|
// Rate limit / timing constants
|
|
export const VISITOR_COOKIE = 'vbn_vid'
|
|
export const CONSENT_COOKIE = 'vbn_analytics_consent'
|
|
export const MAX_RECENT_VISITS = 1000
|
|
export const VISITOR_RETENTION_DAYS_DEFAULT = 180
|
|
export const BACKUP_RETENTION_DAYS = 30
|
|
export const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
export const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
|
|
export const MAX_CONTACT_SUBMISSIONS = 5000
|
|
export const CONTACT_EMAIL_COOLDOWN_MS = Math.max(10 * 1000, Number(process.env.CONTACT_EMAIL_COOLDOWN_MS ?? 60 * 1000) || 60 * 1000)
|
|
export const DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000
|
|
|
|
export const MAX_QUESTIONS = 1000
|
|
export const MAX_STUDY_COMMENTS = 10000
|
|
export const MAX_STUDY_COMMENT_LENGTH = 2000
|
|
export const MAX_STUDY_USERS = 5000
|
|
export const MAX_STUDY_ENROLLMENTS_PER_USER = 100
|
|
export const MAX_STUDY_NOTES_PER_USER = 500
|
|
export const MAX_STUDY_NOTE_LENGTH = 12000
|
|
export const EMAIL_CHANGE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
|
|
export const STUDY_SESSION_COOKIE = 'vbn_study_session'
|
|
export const STUDY_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
export const STUDY_TOTP_PENDING_TTL_MS = 5 * 60 * 1000
|
|
export const EMAIL_OTP_TTL_MS = 10 * 60 * 1000
|
|
export const EMAIL_OTP_MAX_ATTEMPTS = 5
|
|
|
|
export const USE_RESEND_AUTOMATION_WELCOME = process.env.RESEND_AUTOMATION_WELCOME === 'true'
|
|
export const DEFAULT_RESEND_FROM = 'Verse by Verse with Nate <hello@versebyversewithnate.us>'
|
|
export const DEFAULT_RESEND_TO = 'hello@versebyversewithnate.us'
|
|
export const DEFAULT_RESEND_REPLY_TO = 'hello@versebyversewithnate.us'
|
|
export const ADMIN_REPLY_FROM = DEFAULT_RESEND_FROM
|
|
export const NATE_RESEND_FROM = 'Verse by Verse with Nate <nate@versebyversewithnate.us>'
|
|
// Addresses an admin may send a reply from — must stay in sync with the <select> options in src/AdminPage.tsx.
|
|
export const ADMIN_REPLY_FROM_OPTIONS = [DEFAULT_RESEND_FROM, NATE_RESEND_FROM]
|
|
|
|
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 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_REPLY_TEMPLATES = [
|
|
{
|
|
id: 'thanks-for-reaching-out',
|
|
label: 'Thank You Reply',
|
|
subject: 'Thanks for reaching out to Verse by Verse with Nate',
|
|
message: 'Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally.',
|
|
},
|
|
{
|
|
id: 'question-received',
|
|
label: 'Question Received',
|
|
subject: 'Your Bible question was received',
|
|
message: 'Thank you for sending your Bible question.\n\nI have received it, and I appreciate you taking the time to write in.',
|
|
},
|
|
{
|
|
id: 'testimony-thank-you',
|
|
label: 'Testimony Thank You',
|
|
subject: 'Thank you for sharing your testimony',
|
|
message: 'Thank you for sharing what the Lord is doing in your life.\n\nYour message was an encouragement to read.',
|
|
},
|
|
]
|
|
|
|
export const DEFAULT_PODCAST_CHECKLIST_TASKS = [
|
|
{ id: 'verify_script', label: 'Verify Script', phase: 'pre' },
|
|
{ id: 'read_script', label: 'Read Script', phase: 'pre' },
|
|
{ id: 'record', label: 'Record', phase: 'pre' },
|
|
{ id: 'mix', label: 'Mix', phase: 'pre' },
|
|
{ id: 'edit', label: 'Edit', phase: 'pre' },
|
|
{ id: 'video_script', label: 'Run Video Conversion Script', phase: 'pre' },
|
|
{ id: 'post_spotify', label: 'Post on Spotify', phase: 'pre' },
|
|
{ id: 'update_website', label: 'Update Website', phase: 'post' },
|
|
{ id: 'send_email', label: 'Send Email', phase: 'post' },
|
|
]
|
|
|
|
export const EMPTY_HIT_STATS = {
|
|
totalHits: 0,
|
|
realHits: 0,
|
|
botHits: 0,
|
|
firstHitAt: null,
|
|
lastHitAt: null,
|
|
byPath: {},
|
|
byPathReal: {},
|
|
byPathBot: {},
|
|
byDay: {},
|
|
byDayReal: {},
|
|
byDayBot: {},
|
|
botReasons: {},
|
|
}
|
|
|
|
export const EMPTY_VISITOR_STATS = {
|
|
totalVisits: 0,
|
|
uniqueVisitors: 0,
|
|
returningVisits: 0,
|
|
firstVisitAt: null,
|
|
lastVisitAt: null,
|
|
visitors: {},
|
|
ipHashIndex: {},
|
|
recentVisits: [],
|
|
geoCacheByIp: {},
|
|
}
|
|
|
|
// ── Podcast checklist helpers (no state dependency) ────────────────────────
|
|
|
|
function buildChecklistEpisode(series, number) {
|
|
const tasks = {}
|
|
for (const task of DEFAULT_PODCAST_CHECKLIST_TASKS) {
|
|
tasks[task.id] = false
|
|
}
|
|
return {
|
|
id: `${series.toLowerCase()}-${number}`,
|
|
series,
|
|
episodeNumber: number,
|
|
title: '',
|
|
datePublished: '',
|
|
expanded: false,
|
|
tasks,
|
|
}
|
|
}
|
|
|
|
export function buildDefaultPodcastChecklist() {
|
|
const titusEpisodes = [11, 12, 13, 14, 15].map(number => buildChecklistEpisode('Titus', number))
|
|
const colossiansEpisodes = Array.from({ length: 27 }, (_, index) => buildChecklistEpisode('Colossians', index + 1))
|
|
return {
|
|
tasks: DEFAULT_PODCAST_CHECKLIST_TASKS,
|
|
episodes: [...titusEpisodes, ...colossiansEpisodes],
|
|
}
|
|
}
|