iPadOS greys out .tar.gz files when the input has an accept filter, so
the archive could not be selected. The server validates the gzip
signature and archive contents before restoring, so no client-side
filter is needed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Browsers drop Secure cookies on plain-http origins, so logging into the
admin over a LAN/VPN IP (e.g. during server migration, before TLS is in
front) silently failed every authenticated request. Setting
ALLOW_INSECURE_COOKIES=true omits the Secure flag; default behavior in
production is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- New admin endpoints: GET /api/admin-backup/export streams the entire
data directory as tar.gz (after flushing queued writes); POST
/api/admin-backup/import validates the archive, snapshots current
data, replaces the folder, and reloads all in-memory state.
- Admin UI: "Full Data Backup" section with download and restore-from-
file controls in the Analytics panel.
- Fix admin TOTP secret path to honor SITEFORGE_DATA_DIR (moved base
path resolution to server/paths.js to avoid a circular import), so
2FA for admin and study users survives backup/restore.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 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>
- Reply composer shows a From dropdown (hello@ or nate@), defaulting
to whichever address the inbound email was sent to
- Server validates the chosen address against an allowlist before sending
- Inbox list shows colored address badges: blue for hello@, purple for nate@
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Silently checks for new contact submissions in the background and
prepends any new ones to the inbox without disrupting the current view.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Templates now only fill the message body, not the subject, so the
Re: [original subject] line stays intact for proper Gmail threading.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When replying to an inbound email that has a messageId, the outgoing
Resend payload now includes In-Reply-To and References headers so the
reply threads correctly in Gmail and other email clients.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Worker now extracts both text/plain and text/html MIME parts and sends
htmlBody in the webhook payload. Server stores it on the submission.
Admin inbox renders htmlBody in a sandboxed iframe when present, falling
back to plain text with whitespace preserved.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Cloudflare Email Worker forwards hello@ and nate@ to Gmail and POSTs
parsed MIME email to /api/inbound-email as admin inbox entries
- New server route authenticates via shared secret and deduplicates by
Message-ID before storing inbound emails as contact submissions
- Admin inbox shows "email" badge for inbound messages; reply composer
opens blank with auto-subject "Re: [original]" and signature preview
- Documents setup steps in cloudflare/email-worker.js and .env.example
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Public:
- /finished page with ornament heading, intro text, book grid, and styled empty state
- /finished/:id episode playlist page filtered by RSS season number
- Episodes nav becomes click-toggle dropdown (Current Series / Finished Books)
- Finished Books link in footer
- Dropdown font fixed to match other nav links
Admin:
- End Current Series & Start New Book wizard on Current Series tab
- Finished Books management tab with add/edit/remove and image asset picker
Data:
- FinishedBook type + finishedBooks[] field on SiteContent
- RSS parser extracts itunes:season per episode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New FinishedBook type in content.ts + finishedBooks[] field on SiteContent
- RSS parser now extracts itunes:season into each episode object
- /finished grid page and /finished/:id playlist page (filters episodes by season)
- Episodes nav link replaced with dropdown: Current Series / Finished Books
- Finished Books link added to footer
- Admin: "End Current Series & Start New Book" wizard on Current Series tab
- Admin: Finished Books management tab (add/edit/remove entries)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaced broken srcdoc iframe approach with direct script injection so
the TFL syndicate.js runs in page context with access to the DOM it needs.
Also added truthforlife.org and ajax.googleapis.com to CSP script-src,
which was silently blocking the widget and its jQuery dependency.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Newsletter nudge — was calling /api/study-account/profile (wrong endpoint, ignored subscription). Now correctly calls /api/study-account/preferences with PATCH.
Security:
Email regex — replaced the permissive [^\s@]+@[^\s@]+ pattern with a proper RFC-compliant regex in contact.js and downloads.js
Avatar magic bytes — server now checks actual PNG/JPEG/GIF/WEBP header bytes, not just the data URL prefix
Certificate rate limit — public /api/public/certificate/:token now has a 30 req/15min limiter
Session absolute TTL — admin sessions now have a 30-day hard cap; a stolen token can no longer be kept alive indefinitely by passive reads
Account lockout — 5 failed logins locks a study account for 1 hour
CSP headers — Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, and Referrer-Policy headers added globally
Data integrity:
Cascade delete — deleting a study account now also removes their certificates, community posts, comments, and progress file
UX / reliability:
Escape key on modals — all 3 modal groups (study index, notes, account) now close on Escape
Display name min-length — empty spaces-only names rejected; if provided, must be ≥2 chars
Note save rate limit — 30 saves/minute per user max
Analytics fetch timeout — 5s AbortController so a hanging server doesn't block the browser indefinitely
Email validation on signup — frontend catches bad email formats before hitting the server
Cleanup:
Deduplicated download forms — StudyDownloadForm and ResourceDownloadForm now share a single DownloadForm base; both are now thin wrappers
Each visit is now a card showing name/IP, page path, location, device,
visit count, new/returning tag, and timestamp at a glance. Logged-in users
show their display name prominently. Click to expand still shows full page
history and raw details. Adds a purple "Logged in" tag for study users.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tracks scroll depth (25/50/75/90%), time on page, UTM parameters, outbound
link clicks, search queries, audio pause/completion/listen time, and 404s.
Logged-in study users are now tied to their visitor record and surfaced in
the admin recent visits table. Scroll position resets on every route change.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- POST /api/analytics/play records a play event (title + date) on first
play per player mount, skipping admin sessions
- Plays persisted to data/episode-plays.json with total + byDay buckets
- Admin stats response includes episodePlays sorted by total plays
- AnalyticsPanel shows horizontal bar chart + summary cards for all
episodes dynamically — new episodes appear automatically as played
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add /api/episode-audio route returning MP3 URLs from Anchor RSS feed
- Replace Spotify URL text inputs in admin with RSS episode dropdowns
- New EpisodeAudioPlayer component with podcast art, show name, progress
bar, and Spotify icon link — full and compact sizes
- Backward-compatible: legacy Spotify embed URLs still render as iframes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The .section-resources CSS overrides were breaking the Downloads page
resource link layout. Added .section-external-sites class to the
ExternalSitesSection and scoped all image/layout overrides to it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The snapshot was set from buildSiteContentForSave(form) which runs
normalizeSiteContentForAdmin and rebuilds study objects, producing a
JSON string that never matched the raw form state. isDirty therefore
stayed true permanently after every save.
Fix by capturing JSON.stringify(form) before the fetch and using that
as the snapshot, so isDirty correctly clears on success.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the configured imageUrl as a full-width banner at the top of each
External Sites card, above the link name and description.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Each QR code gets a /qr/<slug> redirect that logs scans (IP, user agent,
timestamp) to disk. The admin QR Codes view lets you add, edit, enable/disable,
and delete codes, with per-code scan history inline.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Lessons release at 1:00 AM, but reminders fired immediately, landing
around 9 PM the prior evening. Delay sends by 8 hours so they go out
at 9:00 AM on the release day.
- Replace standalone StudySectionComments with CommunityBoard locked to
section tag on lesson page — lesson discussion now feeds directly into
the community system
- Quiz "share to discussion" posts to community API (not separate comments)
- "Go to Community" button no longer pre-filters by section (lesson
discussion is now on-page)
- Add "See all community posts" link from lesson discussion block
- Fix search: download results now use correct custom-- ID format
- Fix search: episodes with a highlight page route to /episodes/:id
internally instead of opening the external link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Phase 1 — Quick wins:
- Image lazy-loading on series/resource cards
- Newsletter signup added to Episodes page (before highlights)
- Per-route meta tags via usePageMeta hook (title, og:title, og:description)
- Breadcrumbs on study index, section, and notes pages
- SVG completion checkmark badges on study section list
- Analytics time-range filter (7d / 30d / 90d) in admin panel
Phase 2 — Medium features:
- Related episodes on archived series detail pages
- Resource library two-tier filter (type + tag chips)
- Global search (Fuse.js) moved below sticky header as full-width bar
- Q&A anonymous upvoting with localStorage dedup + admin pin/unpin
- Study enrollment funnel tracking (firstVisitAt, firstCompletionAt) with funnel chart in analytics
Phase 3 — Larger features:
- Study section comments (auto-approve for enrolled users, admin moderation panel)
- Study completion certificate (canvas render, PNG download, shareable public URL)
- Episode script full-text search (mammoth docx extraction, server-side search, admin upload UI)
- Reflection questions renamed from Discussion Questions; quiz answers can be shared to section discussion
- Public certificate route at /certificate/:token with og meta tags
- Comment moderation panel added to admin under Manage > Study Comments
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- **Tag filter bar** appears above the grid whenever any download has tags assigned. Chips for "All" + each unique tag, centered under the heading.
- **Clicking a tag** filters the grid to only matching downloads. Clicking the active tag again (or "All") clears the filter.
- **Empty state** — if a tag has no results it shows a friendly message instead of a blank grid.
- The filter only shows when tags exist — so it's invisible today (your 3 current downloads have no tags) and appears automatically once you start tagging items in the admin.
To use it: go to Admin → Downloads → edit a download item → add tags like `Colossians`, `Worksheet`, `Devotional`. Those tags will show up as filter chips on the public page instantly after publishing.