diff --git a/package.json b/package.json index 7867ecd..4cdacf9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.0.0", + "version": "2.0.1", "type": "module", "scripts": { "dev": "vite", diff --git a/server/helpers.js b/server/helpers.js index 78de382..c736f54 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -330,6 +330,32 @@ function sanitizeArchivedSeries(value) { ) } +function sanitizeNuggets(raw) { + const n = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {} + const hosts = Array.isArray(n.hosts) + ? n.hosts + .filter(h => h && typeof h === 'object' && typeof h.id === 'string' && h.id.trim()) + .map(h => ({ + id: h.id.trim(), + name: typeof h.name === 'string' ? h.name.trim().slice(0, 120) : '', + role: typeof h.role === 'string' ? h.role.trim().slice(0, 120) : '', + bio: typeof h.bio === 'string' ? h.bio.trim().slice(0, 600) : '', + imageUrl: sanitizeUrl(h.imageUrl) || '', + archived: Boolean(h.archived), + })) + : [] + return { + title: typeof n.title === 'string' ? n.title.trim().slice(0, 120) : '', + tagline: typeof n.tagline === 'string' ? n.tagline.trim().slice(0, 240) : '', + description: typeof n.description === 'string' ? n.description.trim().slice(0, 600) : '', + podcastArtUrl: sanitizeUrl(n.podcastArtUrl) || '', + rssFeedUrl: sanitizeUrl(n.rssFeedUrl) || '', + spotifyUrl: sanitizeUrl(n.spotifyUrl) || '', + appleUrl: sanitizeUrl(n.appleUrl) || '', + hosts, + } +} + export function sanitizeSiteContent(siteContent) { if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return {} @@ -345,6 +371,7 @@ export function sanitizeSiteContent(siteContent) { colossiansStudySections: sanitizeColossiansStudySections(siteContent.colossiansStudySections), redirects: sanitizeRedirectRules(siteContent.redirects), podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS), + nuggets: sanitizeNuggets(siteContent.nuggets), 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) : '', diff --git a/server/routes/analytics.js b/server/routes/analytics.js index 4f9dd38..c65b410 100644 --- a/server/routes/analytics.js +++ b/server/routes/analytics.js @@ -213,7 +213,11 @@ export function register(app) { })() // Filter byDayReal/byDayBot keys to only those within the range - const filteredDayKeys = Object.keys(state.hitStats.byDayReal ?? {}).filter(day => day >= cutoffDate) + const allDayKeys = new Set([ + ...Object.keys(state.hitStats.byDayReal ?? {}), + ...Object.keys(state.hitStats.byDayBot ?? {}), + ]) + const filteredDayKeys = [...allDayKeys].filter(day => day >= cutoffDate) // Aggregate hits for the range const rangeRealHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayReal?.[day] ?? 0), 0) @@ -222,7 +226,7 @@ export function register(app) { // Filter per-path stats by range — approximate using recent visitor rows scoped to range const rangeVisitorRows = state.visitorStats.recentVisits.filter(row => { - if (!row.at) return true // include if no timestamp + if (!row.at) return false // exclude rows with no timestamp from range views return row.at >= cutoffDate }) diff --git a/server/routes/episodes.js b/server/routes/episodes.js index 190af4b..14d4fad 100644 --- a/server/routes/episodes.js +++ b/server/routes/episodes.js @@ -14,15 +14,20 @@ export async function fetchAllEpisodes() { } const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 8000) - const response = await fetch(RSS_FEED_URL, { signal: controller.signal }) - clearTimeout(timeout) - if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`) - const xml = await response.text() - rawXmlCache = xml - const episodes = parseRssItems(xml) - episodesCache = episodes - episodesCacheAt = now - return episodes + try { + const response = await fetch(RSS_FEED_URL, { signal: controller.signal }) + clearTimeout(timeout) + if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`) + const xml = await response.text() + rawXmlCache = xml + const episodes = parseRssItems(xml) + episodesCache = episodes + episodesCacheAt = now + return episodes + } catch (err) { + clearTimeout(timeout) + throw err + } } export async function fetchRawFeedXml() { diff --git a/src/App.tsx b/src/App.tsx index 5fd745a..88750da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1885,11 +1885,11 @@ function NuggetsPage({ content }: { content: SiteContent }) { const [episodes, setEpisodes] = useState([]) const [loading, setLoading] = useState(true) - const [configured, setConfigured] = useState(true) + const [configured, setConfigured] = useState(false) useEffect(() => { fetch('/api/nuggets/episodes/all') - .then(r => r.json()) + .then(r => r.ok ? r.json() : Promise.reject(r.status)) .then((d: { episodes?: NuggetsEpisode[]; configured?: boolean }) => { setEpisodes(Array.isArray(d.episodes) ? d.episodes : []) setConfigured(d.configured !== false)