From 6a5d525d6e8146322729409c8be4ddbaf7f3999e Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 15 Oct 2026 11:30:05 -0400 Subject: [PATCH] Fix Scripture Index doubled/unparseable refs, Titus passage fetch 400, Ko-fi QR 404, auto-load study comments; stop probe URLs inflating Real Hits, real 404 status for unknown URLs; v2.43.1 Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 4 ++-- package.json | 2 +- server.js | 6 ++++-- server/routes/analytics.js | 2 ++ server/routes/bible-passage.js | 7 ++++++- server/routes/public.js | 7 +++++-- server/study-helpers.js | 13 +++++++++++++ src/AdminPage.tsx | 7 +++++++ src/App.tsx | 6 ++++-- test/hit-paths.test.js | 15 +++++++++++++++ 10 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 test/hit-paths.test.js diff --git a/package-lock.json b/package-lock.json index 32a1590..f034b71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "siteforge", - "version": "2.42.1", + "version": "2.43.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "siteforge", - "version": "2.42.1", + "version": "2.43.1", "dependencies": { "@aws-sdk/client-s3": "^3.1135.0", "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index 474a07a..b229a04 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.43.0", + "version": "2.43.1", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index ce77bf1..d3a2503 100644 --- a/server.js +++ b/server.js @@ -39,6 +39,7 @@ import { sanitizeUserAgent, shouldCountHit, recordHit, + isKnownSitePath, scheduleStudyReminders, scheduleReengagementEmails, scheduleWeeklyDigestEmails, @@ -143,8 +144,9 @@ registerSalvation(app) app.use((req, res, next) => { if (shouldCountHit(req)) { const ua = sanitizeUserAgent(req.get('user-agent')) - const botDetection = detectBot(ua) - recordHit(req.path, botDetection.isBot, botDetection.reason) + const knownPath = isKnownSitePath(req.path) + const botDetection = knownPath ? detectBot(ua) : { isBot: true, reason: 'unknown-path' } + recordHit(knownPath ? req.path : '(unknown)', botDetection.isBot, botDetection.reason) queueHitStatsWrite() if (hasVisitorConsent(req) && !botDetection.isBot && !isValidAdminSession(req)) { try { diff --git a/server/routes/analytics.js b/server/routes/analytics.js index 364215f..47e561a 100644 --- a/server/routes/analytics.js +++ b/server/routes/analytics.js @@ -20,6 +20,7 @@ import { buildLastNDaysStats, shouldCountHit, recordHit, + isKnownSitePath, getStudyCatalog, } from '../study-helpers.js' @@ -260,6 +261,7 @@ export function register(app) { const ip = getClientIp(req) if (!pageviewAllowed(ip)) { res.json({ ok: false, reason: 'rate-limited' }); return } const rawPath = typeof req.body?.path === 'string' ? req.body.path : '/' + if (!isKnownSitePath(rawPath)) { res.json({ ok: false, reason: 'unknown-path' }); return } const rawReferrer = typeof req.body?.referrer === 'string' ? req.body.referrer : '' recordHit(rawPath, false) queueHitStatsWrite() diff --git a/server/routes/bible-passage.js b/server/routes/bible-passage.js index aa675d1..135c973 100644 --- a/server/routes/bible-passage.js +++ b/server/routes/bible-passage.js @@ -29,7 +29,12 @@ BIBLE_BOOKS.forEach((b, index) => { }) function parseReference(reference) { - const stripped = reference.replace(/^[a-z]+/i, '') + // Accept a leading book name ("Titus 1:1-2", "1 John 2:3"), en/em dashes and verse-part letters ("1:13a") + const stripped = reference + .replace(/[–—]/g, '-') + .replace(/(\d)[a-z]\b/gi, '$1') + .replace(/^\s*(?:[1-3]\s*)?[a-z][a-z .]*?\s*(?=\d+:)/i, '') + .trim() const match = stripped.match(/^(\d+):(\d+)(?:-(\d+))?$/) if (!match) return null return { diff --git a/server/routes/public.js b/server/routes/public.js index 1153e5a..eb6c4a2 100644 --- a/server/routes/public.js +++ b/server/routes/public.js @@ -16,7 +16,7 @@ import { } from '../config.js' import { state } from '../state.js' import { loadSiteContentFile } from '../data.js' -import { sanitizeRedirectRules } from '../study-helpers.js' +import { sanitizeRedirectRules, isKnownSitePath } from '../study-helpers.js' import { fetchAllEpisodes } from './episodes.js' import { fetchNuggetsEpisodes } from './nuggets.js' import { BIBLE_BOOKS } from '../../shared/bible-books.js' @@ -217,7 +217,8 @@ export function register(app) { if (!sec.id || !sec.reference || !sec.releasedAt) continue if (new Date(sec.releasedAt) > cutoff) continue entries.push({ - reference: `${bookName} ${sec.reference}`, + // Section references often already include the book ("Titus 1:1-2"); only prepend when they don't + reference: /^\s*(?:[1-3]\s*)?[A-Za-z]/.test(sec.reference) ? sec.reference.trim() : `${bookName} ${sec.reference}`, sourceType: 'study', sourceTitle: s.title || s.slug, sectionTitle: sec.title || '', @@ -274,6 +275,8 @@ export function register(app) { app.use(async (_req, res) => { try { const html = await readFile(INDEX_FILE, 'utf8') + // Unknown URLs still render the app's "Page not found" screen, but with a real 404 status + if (!isKnownSitePath(_req.path)) res.status(404) res.type('html').send(injectSeoIntoHtml(html, state.cachedSiteContent)) } catch { res.status(503).send('Frontend build not found. Run "npm run build" first.') diff --git a/server/study-helpers.js b/server/study-helpers.js index d94c57b..47304c3 100644 --- a/server/study-helpers.js +++ b/server/study-helpers.js @@ -476,6 +476,19 @@ export function normalizeHitPath(pathname) { return pathname } +// Top-level URL segments the SPA / static pages actually serve. Anything else is a probe or +// scanner hit, which must not count as a real visit or add a new key to the per-path stats. +const KNOWN_SITE_SEGMENTS = new Set([ + 'start-here', 'study', 'finished', 'episodes', 'nuggets', 'resources', 'downloads', 'about', 'contact', + 'questions', 'newsletter', 'scripture', 'thanks', 'subscribe', 'certificate', 'host', 'admin', 'preview', + 'privacy', 'terms', 'salvation', 'qr', +]) + +export function isKnownSitePath(pathname) { + const first = String(pathname ?? '/').split('/')[1] ?? '' + return first === '' || KNOWN_SITE_SEGMENTS.has(first) +} + export function shouldCountHit(req) { if (req.method !== 'GET') return false if (req.path.startsWith('/api/')) return false diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index cde20c9..7ada038 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -2422,6 +2422,13 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [commentsLoaded, setCommentsLoaded] = useState(false) const [commentFilter, setCommentFilter] = useState<'all' | 'pending' | 'approved'>('all') const [commentSearch, setCommentSearch] = useState('') + // Load moderation queue as soon as the section is opened; a failed load leaves the manual button available + const commentsAutoTried = useRef(false) + useEffect(() => { + if (adminView !== 'study-comments' || commentsLoaded || commentsAutoTried.current) return + commentsAutoTried.current = true + void loadStudyComments() + }, [adminView, commentsLoaded]) // Episode scripts state const [scriptsList, setScriptsList] = useState>([]) diff --git a/src/App.tsx b/src/App.tsx index f89e90b..de4561c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -662,7 +662,7 @@ function KofiSupportSection({ content }: { content: SiteContent }) { {qrUrl && (
- Ko-fi donation QR code + Ko-fi donation QR code { const fig = e.currentTarget.parentElement; if (fig) fig.style.display = 'none' }} />
Scan to give on Ko-fi
)} @@ -2655,7 +2655,9 @@ for (const b of BIBLE_BOOKS) { function parseSIRef(ref: string) { // Supports same-chapter ranges ("5:15-20") and chapter-spanning ranges ("5:15-6:2"). - const m = ref.match(/^(.*?)\s+(\d+):(\d+)(?:-(?:(\d+):)?(\d+))?$/) + // Tolerate en/em dashes and verse-part suffixes like "10-12a" + const clean = ref.replace(/[\u2013\u2014]/g, '-').replace(/(\d)[a-z]\b/gi, '$1').trim() + const m = clean.match(/^(.*?)\s+(\d+):(\d+)(?:-(?:(\d+):)?(\d+))?$/) if (!m) return null const bookId = SI_BOOK_MAP[m[1].toLowerCase().replace(/\.\s*/g, ' ').trim()] if (!bookId) return null diff --git a/test/hit-paths.test.js b/test/hit-paths.test.js new file mode 100644 index 0000000..0efec7b --- /dev/null +++ b/test/hit-paths.test.js @@ -0,0 +1,15 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { isKnownSitePath } from '../server/study-helpers.js' + +test('real site pages are known paths', () => { + for (const p of ['/', '/study', '/study/titus/titus-abc', '/episodes/123', '/salvation', '/scripture', '/privacy']) { + assert.equal(isKnownSitePath(p), true, p) + } +}) + +test('scanner and probe paths are not known paths', () => { + for (const p of ['/.env', '/.git/config', '/wp-login', '/actuator/health', '/cgi-bin/x', '/random-path-123']) { + assert.equal(isKnownSitePath(p), false, p) + } +})