Codebase audit: remove dead code, fix broken patterns, consolidate duplicates; v2.0.27
Publish Container / docker (push) Failing after 10m40s
Publish Container / docker (push) Failing after 10m40s
- Remove unused prop-types dep, deprecated useOutboundLinkTracking export - Fix missing linkClicks key in analyticsEvents initial state (B-01) - Export ADMIN_SESSION_COOKIE from auth.js; remove local copy in admin-auth.js (D-02) - Remove cookieFlags() wrapper in auth.js and study-helpers.js; use cookieSecureFlag() directly (U-04) - Remove ADMIN_SESSION_TTL_MS duplicate from config.js (D-01) - Remove DEFAULT_SEO/DEFAULT_LEGAL/DEFAULT_REDIRECT_RULES/DEFAULT_PODCAST_FEATURED_LINKS/DEFAULT_PUBLISH_STATE duplicates from config.js; re-export from helpers.js (D-04, fixes sitemapPaths divergence) - Fix email.js hardcoded fallback strings to use DEFAULT_RESEND_TO/DEFAULT_RESEND_REPLY_TO (M-02) - Remove duplicate normalizeIp, isPrivateOrLocalIp, normalizeRedirectPath, sanitizeUrl, sanitizeRedirectRules bodies from study-helpers.js; re-export from helpers.js (D-03) - Fix pruneStatsByDays to import VISITOR_RETENTION_DAYS_DEFAULT from config.js (B-02) - Fix createTitusDownloadToken to use DOWNLOAD_TOKEN_TTL_MS constant (B-03) - Remove normalizeMessageType duplicate from study-helpers.js (U-03/D-08) - Remove dead wantsWelcome variable in contact.js; use shouldSendWelcome (B-04) - Replace dynamic imports in contact.js with static imports (B-07) - Remove dead wantsWelcome=true in study-auth.js (B-05) - Merge duplicate import blocks in analytics.js and public.js (D-05, D-06) - Replace dynamic readFile re-import in nuggets-host-upload.js (B-06) - Replace hardcoded SITE_URL fallbacks with getCanonicalBaseUrl() (M-06) - Replace raw resend.emails.send in study-account.js with sendResendEmailWithRetry (M-01) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
1ddb8ee7be
commit
e158e1e53a
14 files changed
+58
-185
No files matched your search
+1
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.26",
|
"version": "2.0.27",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -26,7 +26,6 @@
|
|||||||
"mammoth": "^1.12.0",
|
"mammoth": "^1.12.0",
|
||||||
"multer": "^2.3.0",
|
"multer": "^2.3.0",
|
||||||
"otplib": "^13.4.0",
|
"otplib": "^13.4.0",
|
||||||
"prop-types": "^15.8.1",
|
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-chartjs-2": "^5.2.0",
|
"react-chartjs-2": "^5.2.0",
|
||||||
|
|||||||
+3
-7
@@ -11,16 +11,12 @@ const TOTP_SECRET_FILE = path.join(DATA_DIR, 'totp-secret.json')
|
|||||||
const TOTP_PENDING_TTL_MS = 5 * 60 * 1000
|
const TOTP_PENDING_TTL_MS = 5 * 60 * 1000
|
||||||
const totpPendingSessions = new Map()
|
const totpPendingSessions = new Map()
|
||||||
|
|
||||||
const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
|
export const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
|
||||||
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
const ADMIN_SESSION_ABSOLUTE_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
const ADMIN_SESSION_ABSOLUTE_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
||||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
|
||||||
const adminSessions = new Map()
|
const adminSessions = new Map()
|
||||||
|
|
||||||
function cookieFlags() {
|
|
||||||
return cookieSecureFlag()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sha256(value) {
|
export function sha256(value) {
|
||||||
return createHash('sha256').update(String(value)).digest('hex')
|
return createHash('sha256').update(String(value)).digest('hex')
|
||||||
}
|
}
|
||||||
@@ -254,14 +250,14 @@ export function isValidAdminSession(req) {
|
|||||||
export function setAdminSessionCookie(res, token) {
|
export function setAdminSessionCookie(res, token) {
|
||||||
res.append(
|
res.append(
|
||||||
'Set-Cookie',
|
'Set-Cookie',
|
||||||
`${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
`${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAdminSessionCookie(res) {
|
export function clearAdminSessionCookie(res) {
|
||||||
res.append(
|
res.append(
|
||||||
'Set-Cookie',
|
'Set-Cookie',
|
||||||
`${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
`${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-53
@@ -5,6 +5,7 @@ import { validateAdminPasswordSetup } from './auth.js'
|
|||||||
import { ROOT_DIR, DATA_DIR } from './paths.js'
|
import { ROOT_DIR, DATA_DIR } from './paths.js'
|
||||||
|
|
||||||
export { ROOT_DIR, DATA_DIR }
|
export { ROOT_DIR, DATA_DIR }
|
||||||
|
export { DEFAULT_SEO, DEFAULT_LEGAL, DEFAULT_REDIRECT_RULES, DEFAULT_PODCAST_FEATURED_LINKS, DEFAULT_PUBLISH_STATE } from './helpers.js'
|
||||||
|
|
||||||
function readPackageVersion() {
|
function readPackageVersion() {
|
||||||
try {
|
try {
|
||||||
@@ -103,7 +104,6 @@ export const MAX_RECENT_VISITS = 1000
|
|||||||
export const VISITOR_RETENTION_DAYS_DEFAULT = 180
|
export const VISITOR_RETENTION_DAYS_DEFAULT = 180
|
||||||
export const BACKUP_RETENTION_DAYS = 30
|
export const BACKUP_RETENTION_DAYS = 30
|
||||||
export const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000
|
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 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 CONTACT_EMAIL_COOLDOWN_MS = Math.max(10 * 1000, Number(process.env.CONTACT_EMAIL_COOLDOWN_MS ?? 60 * 1000) || 60 * 1000)
|
||||||
@@ -132,58 +132,6 @@ export const NATE_RESEND_FROM = 'Verse by Verse with Nate <nate@versebyversewith
|
|||||||
// Addresses an admin may send a reply from — must stay in sync with the <select> options in src/AdminPage.tsx.
|
// 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 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 = [
|
export const DEFAULT_REPLY_TEMPLATES = [
|
||||||
{
|
{
|
||||||
id: 'thanks-for-reaching-out',
|
id: 'thanks-for-reaching-out',
|
||||||
|
|||||||
+4
-2
@@ -2,6 +2,8 @@ import { Resend } from 'resend'
|
|||||||
import { escapeHtml, buildAbsoluteUrl, splitName } from './helpers.js'
|
import { escapeHtml, buildAbsoluteUrl, splitName } from './helpers.js'
|
||||||
import {
|
import {
|
||||||
DEFAULT_RESEND_FROM,
|
DEFAULT_RESEND_FROM,
|
||||||
|
DEFAULT_RESEND_TO,
|
||||||
|
DEFAULT_RESEND_REPLY_TO,
|
||||||
DEFAULT_SEO,
|
DEFAULT_SEO,
|
||||||
} from './config.js'
|
} from './config.js'
|
||||||
import { state } from './state.js'
|
import { state } from './state.js'
|
||||||
@@ -13,11 +15,11 @@ export function getResendFromAddress() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getResendReplyToAddress() {
|
export function getResendReplyToAddress() {
|
||||||
return process.env.RESEND_REPLY_TO ?? 'hello@versebyversewithnate.us'
|
return process.env.RESEND_REPLY_TO ?? DEFAULT_RESEND_REPLY_TO
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getResendInboxAddress() {
|
export function getResendInboxAddress() {
|
||||||
return process.env.RESEND_TO ?? 'hello@versebyversewithnate.us'
|
return process.env.RESEND_TO ?? DEFAULT_RESEND_TO
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCanonicalBaseUrl() {
|
export function getCanonicalBaseUrl() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import qrcode from 'qrcode'
|
|||||||
import { parseCookies } from '../helpers.js'
|
import { parseCookies } from '../helpers.js'
|
||||||
import { APP_VERSION, GIT_COMMIT } from '../config.js'
|
import { APP_VERSION, GIT_COMMIT } from '../config.js'
|
||||||
import {
|
import {
|
||||||
|
ADMIN_SESSION_COOKIE,
|
||||||
isAdminPasswordConfigured,
|
isAdminPasswordConfigured,
|
||||||
isValidAdminSession,
|
isValidAdminSession,
|
||||||
requireAdminAuth,
|
requireAdminAuth,
|
||||||
@@ -24,8 +25,6 @@ import {
|
|||||||
consumePendingSession,
|
consumePendingSession,
|
||||||
} from '../auth.js'
|
} from '../auth.js'
|
||||||
|
|
||||||
const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
|
|
||||||
|
|
||||||
const loginRateLimiter = rateLimit({
|
const loginRateLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
max: 10,
|
max: 10,
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import {
|
|||||||
buildLastNDaysStats,
|
buildLastNDaysStats,
|
||||||
shouldCountHit,
|
shouldCountHit,
|
||||||
recordHit,
|
recordHit,
|
||||||
|
getStudyCatalog,
|
||||||
} from '../study-helpers.js'
|
} from '../study-helpers.js'
|
||||||
import { getStudyCatalog } from '../study-helpers.js'
|
|
||||||
|
|
||||||
async function resolveGeo(ip) {
|
async function resolveGeo(ip) {
|
||||||
if (!ip || isPrivateOrLocalIp(ip)) {
|
if (!ip || isPrivateOrLocalIp(ip)) {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import { Resend } from 'resend'
|
import { Resend } from 'resend'
|
||||||
import { requireAdminAuth } from '../auth.js'
|
import { requireAdminAuth } from '../auth.js'
|
||||||
import { escapeHtml, splitName } from '../helpers.js'
|
import { escapeHtml, splitName, buildAbsoluteUrl } from '../helpers.js'
|
||||||
import {
|
import {
|
||||||
MAX_CONTACT_SUBMISSIONS,
|
MAX_CONTACT_SUBMISSIONS,
|
||||||
MAX_QUESTIONS,
|
MAX_QUESTIONS,
|
||||||
USE_RESEND_AUTOMATION_WELCOME,
|
USE_RESEND_AUTOMATION_WELCOME,
|
||||||
DEFAULT_SEO,
|
DEFAULT_SEO,
|
||||||
|
DATA_FILE,
|
||||||
} from '../config.js'
|
} from '../config.js'
|
||||||
import { state } from '../state.js'
|
import { state } from '../state.js'
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
normalizeContactEmailStatus,
|
normalizeContactEmailStatus,
|
||||||
normalizeMessageType,
|
normalizeMessageType,
|
||||||
fireWebhooks,
|
fireWebhooks,
|
||||||
|
loadSiteContentFile,
|
||||||
} from '../data.js'
|
} from '../data.js'
|
||||||
import {
|
import {
|
||||||
noteContactEmailCooldown,
|
noteContactEmailCooldown,
|
||||||
@@ -130,7 +132,6 @@ export function register(app) {
|
|||||||
const submittedAt = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })
|
const submittedAt = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })
|
||||||
const shouldSendWelcome = shouldSendWelcomeEmail({ subscribe })
|
const shouldSendWelcome = shouldSendWelcomeEmail({ subscribe })
|
||||||
|
|
||||||
const wantsWelcome = subscribe === true
|
|
||||||
const submission = {
|
const submission = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
submittedAt: new Date().toISOString(),
|
submittedAt: new Date().toISOString(),
|
||||||
@@ -138,9 +139,9 @@ export function register(app) {
|
|||||||
email: trimmedEmail,
|
email: trimmedEmail,
|
||||||
message: trimmedMessage,
|
message: trimmedMessage,
|
||||||
messageType: normalizedMessageType,
|
messageType: normalizedMessageType,
|
||||||
subscribe: wantsWelcome,
|
subscribe: shouldSendWelcome,
|
||||||
archived: false,
|
archived: false,
|
||||||
emailStatus: normalizeContactEmailStatus(null, wantsWelcome),
|
emailStatus: normalizeContactEmailStatus(null, shouldSendWelcome),
|
||||||
}
|
}
|
||||||
state.contactSubmissions.unshift(submission)
|
state.contactSubmissions.unshift(submission)
|
||||||
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
||||||
@@ -187,8 +188,6 @@ export function register(app) {
|
|||||||
let publishedSiteContent = state.cachedSiteContent
|
let publishedSiteContent = state.cachedSiteContent
|
||||||
if (!publishedSiteContent) {
|
if (!publishedSiteContent) {
|
||||||
try {
|
try {
|
||||||
const { loadSiteContentFile } = await import('../data.js')
|
|
||||||
const { DATA_FILE } = await import('../config.js')
|
|
||||||
const published = await loadSiteContentFile(DATA_FILE)
|
const published = await loadSiteContentFile(DATA_FILE)
|
||||||
publishedSiteContent = published?.siteContent ?? null
|
publishedSiteContent = published?.siteContent ?? null
|
||||||
} catch { publishedSiteContent = null }
|
} catch { publishedSiteContent = null }
|
||||||
@@ -200,7 +199,6 @@ export function register(app) {
|
|||||||
: DEFAULT_SEO.canonicalUrl
|
: DEFAULT_SEO.canonicalUrl
|
||||||
const welcomeSubject = process.env.RESEND_WELCOME_SUBJECT ?? emailConfig.welcomeEmailSubject ?? 'Welcome to Verse by Verse with Nate'
|
const welcomeSubject = process.env.RESEND_WELCOME_SUBJECT ?? emailConfig.welcomeEmailSubject ?? 'Welcome to Verse by Verse with Nate'
|
||||||
const welcomeGreetingPrefix = emailConfig.welcomeEmailGreetingPrefix?.trim() || "Glad you're here"
|
const welcomeGreetingPrefix = emailConfig.welcomeEmailGreetingPrefix?.trim() || "Glad you're here"
|
||||||
const { buildAbsoluteUrl } = await import('../helpers.js')
|
|
||||||
const welcomeTemplate = buildContactWelcomeEmailTemplate({
|
const welcomeTemplate = buildContactWelcomeEmailTemplate({
|
||||||
greetingName,
|
greetingName,
|
||||||
welcomeGreetingPrefix,
|
welcomeGreetingPrefix,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
sendNuggetsUploadNotification,
|
sendNuggetsUploadNotification,
|
||||||
sendNuggetsHostWelcomeEmail,
|
sendNuggetsHostWelcomeEmail,
|
||||||
sendNuggetsHostOtpEmail,
|
sendNuggetsHostOtpEmail,
|
||||||
|
getCanonicalBaseUrl,
|
||||||
} from '../email.js'
|
} from '../email.js'
|
||||||
import { markSeafileFileSeen } from '../nuggets-seafile-watcher.js'
|
import { markSeafileFileSeen } from '../nuggets-seafile-watcher.js'
|
||||||
import {
|
import {
|
||||||
@@ -414,8 +415,7 @@ export function register(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { readFile: rf } = await import('node:fs/promises')
|
const fileBuffer = await readFile(tempPath)
|
||||||
const fileBuffer = await rf(tempPath)
|
|
||||||
|
|
||||||
// Upload audio to /Episode Upload/
|
// Upload audio to /Episode Upload/
|
||||||
const audioLink = await getSeafileUploadLink(SEAFILE_EPISODE_UPLOAD_DIR)
|
const audioLink = await getSeafileUploadLink(SEAFILE_EPISODE_UPLOAD_DIR)
|
||||||
@@ -553,7 +553,7 @@ export function register(app) {
|
|||||||
name,
|
name,
|
||||||
username: email,
|
username: email,
|
||||||
password,
|
password,
|
||||||
portalUrl: `${process.env.SITE_URL ?? 'https://versebyversewithnate.us'}/host`,
|
portalUrl: `${getCanonicalBaseUrl().replace(/\/$/, '')}/host`,
|
||||||
}).catch(err => console.error('[nuggets-host] welcome email error:', err))
|
}).catch(err => console.error('[nuggets-host] welcome email error:', err))
|
||||||
|
|
||||||
const { passwordHash: _ph, ...safeHost } = host
|
const { passwordHash: _ph, ...safeHost } = host
|
||||||
@@ -591,7 +591,7 @@ export function register(app) {
|
|||||||
name: data.hosts[idx].name,
|
name: data.hosts[idx].name,
|
||||||
username: data.hosts[idx].email,
|
username: data.hosts[idx].email,
|
||||||
password: newPassword,
|
password: newPassword,
|
||||||
portalUrl: `${process.env.SITE_URL ?? 'https://versebyversewithnate.us'}/host`,
|
portalUrl: `${getCanonicalBaseUrl().replace(/\/$/, '')}/host`,
|
||||||
}).catch(err => console.error('[nuggets-host] reset email error:', err))
|
}).catch(err => console.error('[nuggets-host] reset email error:', err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import {
|
|||||||
DEFAULT_SEO,
|
DEFAULT_SEO,
|
||||||
APP_VERSION,
|
APP_VERSION,
|
||||||
GIT_COMMIT,
|
GIT_COMMIT,
|
||||||
|
DATA_FILE,
|
||||||
} from '../config.js'
|
} from '../config.js'
|
||||||
import { state } from '../state.js'
|
import { state } from '../state.js'
|
||||||
import { loadSiteContentFile } from '../data.js'
|
import { loadSiteContentFile } from '../data.js'
|
||||||
import { DATA_FILE } from '../config.js'
|
|
||||||
import { sanitizeRedirectRules } from '../study-helpers.js'
|
import { sanitizeRedirectRules } from '../study-helpers.js'
|
||||||
import { fetchAllEpisodes } from './episodes.js'
|
import { fetchAllEpisodes } from './episodes.js'
|
||||||
import { fetchNuggetsEpisodes } from './nuggets.js'
|
import { fetchNuggetsEpisodes } from './nuggets.js'
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
buildBrandedEmailHtml,
|
buildBrandedEmailHtml,
|
||||||
getCanonicalBaseUrl,
|
getCanonicalBaseUrl,
|
||||||
getResendFromAddress,
|
getResendFromAddress,
|
||||||
|
sendResendEmailWithRetry,
|
||||||
} from '../email.js'
|
} from '../email.js'
|
||||||
import { Resend } from 'resend'
|
import { Resend } from 'resend'
|
||||||
|
|
||||||
@@ -381,27 +382,26 @@ export function register(app) {
|
|||||||
const emailChangeSubject = cfg.emailChangeSubject?.trim() || 'Confirm your new email address'
|
const emailChangeSubject = cfg.emailChangeSubject?.trim() || 'Confirm your new email address'
|
||||||
const emailChangeBody = cfg.emailChangeBody?.trim() || 'Click the link below to confirm your new account email. If you did not request this change, ignore this message.'
|
const emailChangeBody = cfg.emailChangeBody?.trim() || 'Click the link below to confirm your new account email. If you did not request this change, ignore this message.'
|
||||||
const emailChangeCtaLabel = cfg.emailChangeCtaLabel?.trim() || 'Confirm Email Change'
|
const emailChangeCtaLabel = cfg.emailChangeCtaLabel?.trim() || 'Confirm Email Change'
|
||||||
const { error } = await resend.emails.send({
|
await sendResendEmailWithRetry({
|
||||||
from: getResendFromAddress(),
|
resend,
|
||||||
to: [newEmail],
|
context: 'study-account-email-change',
|
||||||
subject: emailChangeSubject,
|
payload: {
|
||||||
text: `${emailChangeBody}\n\n${verifyUrl}`,
|
from: getResendFromAddress(),
|
||||||
html: buildBrandedEmailHtml({
|
to: [newEmail],
|
||||||
title: emailChangeSubject,
|
subject: emailChangeSubject,
|
||||||
eyebrow: 'Account Security',
|
text: `${emailChangeBody}\n\n${verifyUrl}`,
|
||||||
bodyHtml: `<p style="margin:0 0 16px;">${escapeHtml(emailChangeBody)}</p>`,
|
html: buildBrandedEmailHtml({
|
||||||
ctaLabel: emailChangeCtaLabel,
|
title: emailChangeSubject,
|
||||||
ctaUrl: verifyUrl,
|
eyebrow: 'Account Security',
|
||||||
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">Verse by Verse with Nate</p>`,
|
bodyHtml: `<p style="margin:0 0 16px;">${escapeHtml(emailChangeBody)}</p>`,
|
||||||
}),
|
ctaLabel: emailChangeCtaLabel,
|
||||||
|
ctaUrl: verifyUrl,
|
||||||
|
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">Verse by Verse with Nate</p>`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if (error) {
|
|
||||||
console.error('[study-account] email change send error:', error)
|
|
||||||
res.status(503).json({ message: 'Could not send verification email right now.' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[study-account] email change send exception:', err)
|
console.error('[study-account] email change send error:', err)
|
||||||
res.status(503).json({ message: 'Could not send verification email right now.' })
|
res.status(503).json({ message: 'Could not send verification email right now.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ export function register(app) {
|
|||||||
queueStudyUsersWrite()
|
queueStudyUsersWrite()
|
||||||
|
|
||||||
if (subscribe) {
|
if (subscribe) {
|
||||||
const wantsWelcome = true
|
|
||||||
const submission = {
|
const submission = {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
@@ -113,9 +112,9 @@ export function register(app) {
|
|||||||
email: username,
|
email: username,
|
||||||
message: '',
|
message: '',
|
||||||
messageType: 'general',
|
messageType: 'general',
|
||||||
subscribe: wantsWelcome,
|
subscribe: true,
|
||||||
archived: false,
|
archived: false,
|
||||||
emailStatus: normalizeContactEmailStatus(null, wantsWelcome),
|
emailStatus: normalizeContactEmailStatus(null, true),
|
||||||
}
|
}
|
||||||
state.contactSubmissions.unshift(submission)
|
state.contactSubmissions.unshift(submission)
|
||||||
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export const state = {
|
|||||||
searchQueries: {},
|
searchQueries: {},
|
||||||
notFound: {},
|
notFound: {},
|
||||||
audioEvents: {},
|
audioEvents: {},
|
||||||
|
linkClicks: {},
|
||||||
},
|
},
|
||||||
analyticsEventsWritePromise: Promise.resolve(),
|
analyticsEventsWritePromise: Promise.resolve(),
|
||||||
|
|
||||||
|
|||||||
+15
-82
@@ -1,6 +1,15 @@
|
|||||||
import { createHash, createHmac, randomUUID } from 'node:crypto'
|
import { createHash, createHmac, randomUUID } from 'node:crypto'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { parseCookies, cookieSecureFlag } from './helpers.js'
|
import {
|
||||||
|
parseCookies,
|
||||||
|
cookieSecureFlag,
|
||||||
|
normalizeIp,
|
||||||
|
isPrivateOrLocalIp,
|
||||||
|
normalizeRedirectPath,
|
||||||
|
sanitizeUrl,
|
||||||
|
sanitizeRedirectRules,
|
||||||
|
} from './helpers.js'
|
||||||
|
export { normalizeIp, isPrivateOrLocalIp, normalizeRedirectPath, sanitizeUrl, sanitizeRedirectRules } from './helpers.js'
|
||||||
import {
|
import {
|
||||||
STUDY_SESSION_COOKIE,
|
STUDY_SESSION_COOKIE,
|
||||||
STUDY_SESSION_TTL_MS,
|
STUDY_SESSION_TTL_MS,
|
||||||
@@ -9,6 +18,8 @@ import {
|
|||||||
EMAIL_OTP_MAX_ATTEMPTS,
|
EMAIL_OTP_MAX_ATTEMPTS,
|
||||||
CONTACT_EMAIL_COOLDOWN_MS,
|
CONTACT_EMAIL_COOLDOWN_MS,
|
||||||
DEFAULT_REDIRECT_RULES,
|
DEFAULT_REDIRECT_RULES,
|
||||||
|
VISITOR_RETENTION_DAYS_DEFAULT,
|
||||||
|
DOWNLOAD_TOKEN_TTL_MS,
|
||||||
} from './config.js'
|
} from './config.js'
|
||||||
import { state } from './state.js'
|
import { state } from './state.js'
|
||||||
import { queueStudyRemindersWrite, queueStudyUsersWrite } from './data.js'
|
import { queueStudyRemindersWrite, queueStudyUsersWrite } from './data.js'
|
||||||
@@ -130,10 +141,6 @@ export function getStudyAvatarUrl(subject) {
|
|||||||
|
|
||||||
// ── Session management ─────────────────────────────────────────────────────
|
// ── Session management ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function cookieFlags() {
|
|
||||||
return cookieSecureFlag()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createStudySession(userId) {
|
export function createStudySession(userId) {
|
||||||
const token = randomUUID()
|
const token = randomUUID()
|
||||||
state.studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS })
|
state.studySessions.set(token, { userId, expiresAt: Date.now() + STUDY_SESSION_TTL_MS })
|
||||||
@@ -143,14 +150,14 @@ export function createStudySession(userId) {
|
|||||||
export function setStudySessionCookie(res, token) {
|
export function setStudySessionCookie(res, token) {
|
||||||
res.append(
|
res.append(
|
||||||
'Set-Cookie',
|
'Set-Cookie',
|
||||||
`${STUDY_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(STUDY_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
`${STUDY_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(STUDY_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearStudySessionCookie(res) {
|
export function clearStudySessionCookie(res) {
|
||||||
res.append(
|
res.append(
|
||||||
'Set-Cookie',
|
'Set-Cookie',
|
||||||
`${STUDY_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
`${STUDY_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +240,7 @@ export function createTitusDownloadToken(email) {
|
|||||||
const token = randomUUID()
|
const token = randomUUID()
|
||||||
state.titusDownloadTokens.set(token, {
|
state.titusDownloadTokens.set(token, {
|
||||||
email,
|
email,
|
||||||
expiresAt: Date.now() + (10 * 60 * 1000),
|
expiresAt: Date.now() + DOWNLOAD_TOKEN_TTL_MS,
|
||||||
})
|
})
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
@@ -381,29 +388,6 @@ export function detectBot(userAgent) {
|
|||||||
return { isBot: false, reason: null }
|
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) {
|
export function buildTopLocations(list, key) {
|
||||||
const counts = {}
|
const counts = {}
|
||||||
for (const row of list) {
|
for (const row of list) {
|
||||||
@@ -455,7 +439,6 @@ export function recordHit(pathname, isBot = false, botReason = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function pruneStatsByDays(daysRaw) {
|
export function pruneStatsByDays(daysRaw) {
|
||||||
const { VISITOR_RETENTION_DAYS_DEFAULT, EMPTY_HIT_STATS: _unused } = { VISITOR_RETENTION_DAYS_DEFAULT: 180 }
|
|
||||||
const days = Number(daysRaw)
|
const days = Number(daysRaw)
|
||||||
const retentionDays = Number.isFinite(days) && days > 0 ? Math.floor(days) : 180
|
const retentionDays = Number.isFinite(days) && days > 0 ? Math.floor(days) : 180
|
||||||
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000
|
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000
|
||||||
@@ -505,51 +488,6 @@ export function pruneStatsByDays(daysRaw) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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 ───────────────────────────────────────────────
|
// ── Contact / email tracking ───────────────────────────────────────────────
|
||||||
|
|
||||||
export function noteContactEmailCooldown(emailAddress) {
|
export function noteContactEmailCooldown(emailAddress) {
|
||||||
@@ -609,11 +547,6 @@ export function extractResendMessageId(result) {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeMessageType(value) {
|
|
||||||
if (value === 'question' || value === 'testimony' || value === 'topic') return value
|
|
||||||
return 'general'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Study reminder scheduler ───────────────────────────────────────────────
|
// ── Study reminder scheduler ───────────────────────────────────────────────
|
||||||
|
|
||||||
export async function scheduleStudyReminders(sendStudyReminderEmail) {
|
export async function scheduleStudyReminders(sendStudyReminderEmail) {
|
||||||
|
|||||||
@@ -125,5 +125,3 @@ export function useLinkClickTracking() {
|
|||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated Use useLinkClickTracking instead */
|
|
||||||
export function useOutboundLinkTracking() { useLinkClickTracking() }
|
|
||||||
Reference in new issue
Block a user