Compare commits

..
5 Commits
166 changed files with 7950 additions and 62746 deletions

No files matched your search

-19
View File
@@ -1,19 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "siteforge",
"runtimeExecutable": "node",
"runtimeArgs": ["--env-file=.env", "server.js"],
"port": 4173,
"autoPort": false
},
{
"name": "siteforge-vite",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173,
"autoPort": false
}
]
}
+1
View File
@@ -1,4 +1,5 @@
node_modules node_modules
dist
.git .git
.gitignore .gitignore
.vscode .vscode
+1
View File
@@ -0,0 +1 @@
ADMIN_PASSWORD=TestAdmin123!
-5
View File
@@ -21,8 +21,3 @@ RESEND_SEGMENT_ID=
# During testing you can leave this as-is (uses Resend's shared domain). # During testing you can leave this as-is (uses Resend's shared domain).
# For production: verify your own domain at resend.com/domains and change this. # For production: verify your own domain at resend.com/domains and change this.
RESEND_FROM=Verse by Verse with Nate <hello@versebyversewithnate.us> RESEND_FROM=Verse by Verse with Nate <hello@versebyversewithnate.us>
# Shared secret for the Cloudflare Email Worker webhook.
# Must match the WEBHOOK_SECRET variable set in the Cloudflare Worker settings.
# Generate with: openssl rand -hex 32
INBOUND_EMAIL_SECRET=
-71
View File
@@ -1,71 +0,0 @@
name: Publish Container
on:
push:
branches: ["main", "beta"]
tags: ["v*"]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Lint
run: npm run lint
- name: Run tests
run: npm test
- name: Run production build
run: npm run build
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.necloud.us
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: git.necloud.us/nate/siteforge
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' }}
type=sha
type=ref,event=tag
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
COMMIT_SHA=${{ github.sha }}
BUILD_NUMBER=${{ github.run_number }}
+9 -22
View File
@@ -2,7 +2,7 @@ name: Publish Container
on: on:
push: push:
branches: ["main", "beta"] branches: ["main"]
tags: ["v*"] tags: ["v*"]
workflow_dispatch: workflow_dispatch:
@@ -13,8 +13,6 @@ permissions:
jobs: jobs:
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -26,37 +24,30 @@ jobs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v3
with: with:
node-version: '22' node-version: '20'
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: npm ci
- name: Lint
run: npm run lint
- name: Run tests
run: npm test
- name: Run production build - name: Run production build
run: npm run build run: npm run build
- name: Log in to Gitea Container Registry - name: Log in to GHCR
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: git.necloud.us registry: ghcr.io
username: ${{ secrets.REGISTRY_USER }} username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata - name: Extract metadata
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: git.necloud.us/nate/siteforge images: ghcr.io/nmemmert/siteforge
tags: | tags: |
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' }}
type=sha type=sha
type=ref,event=tag type=ref,event=tag
@@ -65,9 +56,5 @@ jobs:
with: with:
context: . context: .
push: true push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: |
COMMIT_SHA=${{ github.sha }}
BUILD_NUMBER=${{ github.run_number }}
-36
View File
@@ -18,19 +18,6 @@ dist-ssr
# Uncomment the line below if you do NOT want to track live data in git. # Uncomment the line below if you do NOT want to track live data in git.
# data/admin-content.json # data/admin-content.json
data/backups/ data/backups/
data/hit-stats.json
data/visitor-stats.json
data/contact-submissions.json
data/totp-secret.json
data/uploads-meta.json
data/study-sessions.json
# Local environment secrets — never commit
.env
.env.*
# Claude Code agent worktrees — local scratch space, never part of the repo
.claude/worktrees/
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
@@ -42,26 +29,3 @@ data/study-sessions.json
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Persisted admin sessions (hashed tokens)
data/admin-sessions.json
# Per-user / visitor data — lives only in the server's data volume, never in git
data/study-users.json
data/study-notes/
data/study-progress/
data/study-reminders.json
data/study-community.json
data/analytics-events.json
data/admin-reply-history.json
data/episode-plays.json
# Episode workflow progress — lives only in the server data volume
data/podcast-workflow.json
data/nuggets-workflow.json
# Live download counters — server data volume only
data/download-counts.json
# Generated audio (upload via Admin → Asset Manager, do not commit)
audio-out/
Binary file not shown.
+9 -17
View File
@@ -1,9 +1,11 @@
# Install prod deps on the native builder platform to avoid QEMU npm crashes FROM node:22-alpine AS build
# during cross-platform builds. All deps are pure-JS so this is safe.
FROM --platform=$BUILDPLATFORM node:22-alpine AS deps
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev --legacy-peer-deps RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime FROM node:22-alpine AS runtime
WORKDIR /app WORKDIR /app
@@ -11,19 +13,11 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=4173 ENV PORT=4173
ARG COMMIT_SHA=unknown COPY package*.json ./
ARG BUILD_NUMBER=unknown RUN npm ci --omit=dev
ENV BUILD_COMMIT=${COMMIT_SHA}
ENV BUILD_NUMBER=${BUILD_NUMBER}
COPY --from=deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist
COPY package.json ./package.json
COPY dist ./dist
COPY server.js ./server.js COPY server.js ./server.js
COPY server/ ./server/
COPY shared/ ./shared/
COPY A_Study_of_Titus.pdf ./A_Study_of_Titus.pdf
# Copy seed data to a separate directory so the entrypoint can seed /app/data # Copy seed data to a separate directory so the entrypoint can seed /app/data
# only when no live data exists yet — upgrades never overwrite existing data. # only when no live data exists yet — upgrades never overwrite existing data.
@@ -35,6 +29,4 @@ COPY entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh RUN chmod +x ./entrypoint.sh
EXPOSE 4173 EXPOSE 4173
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||4173)+'/api/admin-auth/status').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["./entrypoint.sh"]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

+140 -225
View File
@@ -1,258 +1,173 @@
# Siteforge # Siteforge
A podcast and ministry website for **Verse by Verse with Nate** — a verse-by-verse Bible teaching podcast. Built with React + TypeScript (Vite) on the frontend and Express.js on the backend, deployed as a Docker container via GitHub Actions. This project is a React + TypeScript portfolio app built with Vite.
## Features It is designed to showcase two categories of work:
- **Podcast episodes** — episode listing, detail pages, downloadable resources, finished series archive - Websites
- **Study system** — per-user enrollment in structured Bible studies with sections, notes, progress tracking, section quizzes, community posts/comments, and completion certificates - Apps
- **Study accounts** — signup/login, email verification, avatar upload, email change with re-verification
- **Two-factor authentication** — TOTP authenticator app + email OTP backup, recovery codes
- **Contact form** — submissions stored server-side, admin reply with templates, inbound email webhook (Resend)
- **Q&A** — public question submission, admin moderation, pinned answers, shareable question links
- **Email integration** — welcome emails, admin notifications, admin replies, study reminder emails (via Resend)
- **Admin panel** — content management with draft/publish workflow, episode scripts, podcast checklist, QR code management, subscriber management, asset uploads
- **Analytics** — page views, audio plays, download tracking, geo-based visitor stats (country/state/city), consent-based cookie opt-in
- **Backup system** — automatic JSON snapshots with restore, full `tar.gz` data export/import for server migration
- **Version display** — app version and git commit shown in admin sidebar; `/api/version` endpoint
--- The app now supports:
## Tech Stack - Dynamic cards generated from a project data source
- Built vs Hosted filters
- A dedicated About page route for each project
- An admin editor route to update cards and About pages
- A per-project README summary block on each project page
- Embedded Spotify podcast player on the home section
| Layer | Technology | ## Run locally
|---|---|
| Frontend | React 19, TypeScript, Vite, React Router v7 |
| Backend | Express 5, Node.js 22 |
| Email | Resend |
| TOTP | otplib |
| QR Codes | qrcode |
| Rich text | TipTap |
| Charts | Chart.js + react-chartjs-2, react-simple-maps |
| Documents | docx, mammoth |
| Container | Docker (GHCR), GitHub Actions CI |
---
## Project Structure
```
siteforge/
├── src/ # React frontend
│ ├── App.tsx # Root + all client routes
│ ├── App.css
│ └── ... # Page and component files
├── server/ # Express backend
│ ├── config.js # Constants, env vars, APP_VERSION, GIT_COMMIT
│ ├── data.js # In-memory state + JSON persistence
│ ├── helpers.js # Shared utilities (IP, rate limiting, etc.)
│ ├── study-helpers.js # Study email scheduling, cert generation
│ └── routes/ # Route handlers (one file per domain)
├── data/ # Seed data (JSON); live data at /app/data in container
├── server.js # Entry point — mounts all routers
├── Dockerfile
├── entrypoint.sh # Seeds /app/data on first run, never overwrites live data
├── docker-compose.yml
└── .github/workflows/docker-publish.yml
```
---
## Environment Variables
| Variable | Required | Description |
|---|---|---|
| `SESSION_SECRET` | Yes | Secret for Express session signing |
| `ADMIN_PASSWORD_HASH` | Yes | bcrypt hash of the admin password |
| `RESEND_API_KEY` | Yes | Resend API key for sending email |
| `RESEND_FROM` | Yes | Sender address (e.g. `Nate <nate@example.com>`) |
| `RESEND_TO` | Yes | Address that receives contact form notifications |
| `RESEND_REPLY_TO` | No | Reply-to address on outbound emails |
| `RESEND_WEBHOOK_TOKEN` | No | Validates inbound Resend webhook requests |
| `INBOUND_EMAIL_SECRET` | No | Validates inbound email route requests |
| `RESEND_CONTACTS_API_KEY` | No | Resend Contacts API key for subscriber sync |
| `RESEND_SEGMENT_ID` | No | Resend segment ID for subscriber list |
| `COMMIT_SHA` | No | Git SHA baked in at build time by CI (shown in admin UI) |
| `TRUST_PROXY_HOPS` | No | Number of proxy hops to trust (default `1`) — set correctly for accurate client IPs behind a reverse proxy |
| `ALLOW_INSECURE_COOKIES` | No | Set to `true` to allow session cookies over HTTP (for local/non-HTTPS access) |
| `TITUS_STUDY_FILE` | No | Path to the Titus study PDF (defaults to bundled file) |
| `TITUS_STUDY_DOWNLOAD_NAME` | No | Filename shown to users downloading the Titus PDF |
| `PORT` | No | HTTP port (default `4173`) |
| `CACHE_PURGE_WEBHOOK_URL` | No | Webhook URL for one-click cache purge from Admin Operations |
| `DEPLOY_WEBHOOK_URL` | No | Webhook URL for one-click deploy trigger from Admin Operations |
---
## Frontend Routes
| Path | Page |
|---|---|
| `/` | Landing page |
| `/start-here` | Start here / intro page |
| `/episodes` | Episode listing |
| `/episodes/:id` | Episode detail |
| `/finished` | Finished series listing |
| `/finished/:id` | Finished series detail |
| `/resources` | Resources listing |
| `/downloads/:id` | Download detail |
| `/about` | About page |
| `/contact` | Contact form |
| `/questions` | Q&A listing |
| `/subscribe` | Email subscribe |
| `/subscribe/thanks` | Subscribe confirmation |
| `/study` | Study landing page |
| `/study/signup` | Study account signup |
| `/study/account` | Study account dashboard |
| `/study/:studySlug` | Study index |
| `/study/:studySlug/community` | Study community posts |
| `/study/:studySlug/notes` | Study notes |
| `/study/:studySlug/:sectionId` | Study section |
| `/study/:studySlug/:sectionId/quiz` | Section quiz |
| `/certificate/:token` | Public completion certificate |
| `/privacy` | Privacy policy |
| `/thanks` | Contact thank-you |
| `/admin` | Admin panel (requires password + TOTP) |
| `/preview` | Content draft preview |
---
## Key API Endpoints
| Endpoint | Description |
|---|---|
| `GET /api/version` | Returns `{ version, commit }` |
| `GET /api/content` | Public site content (CMS data) |
| `POST /api/contact` | Submit contact form |
| `POST /api/analytics/pageview` | Record page view |
| `POST /api/analytics/play` | Record audio play |
| `GET /api/episode-audio` | Episode audio proxy |
| `GET /api/admin-auth/status` | Admin session status + version info |
| `POST /api/admin-auth/login` | Admin login |
| `POST /api/admin-auth/totp-verify` | Complete TOTP 2FA |
| `POST /api/admin-auth/totp-setup-init` | Begin TOTP enrollment |
| `POST /api/admin-auth/totp-setup-confirm` | Confirm TOTP enrollment |
| `GET /api/admin-stats` | Hit/visitor analytics |
| `GET /api/admin-stats/backup` | Trigger stats snapshot |
| `GET /api/admin-backup/export` | Download full `tar.gz` data backup |
| `POST /api/admin-backup/import` | Restore from `tar.gz` backup |
| `GET /api/admin/study-users` | List study accounts |
| `GET /api/admin-study-certificates` | Study completion certificates |
| `GET /api/admin-contact-submissions` | Contact form submissions |
| `POST /api/admin-contact-submissions/:id/reply` | Reply to a submission |
| `GET /api/admin/qr-codes` | QR code management |
| `GET /api/admin-episode-scripts/:episodeNumber` | Episode script |
| `GET /api/admin-subscribers` | Subscriber list |
---
## Development
```bash ```bash
# Install dependencies
npm install npm install
# Start frontend dev server only (Vite, http://localhost:5173)
npm run dev
# Start backend API server only (reads .env, http://localhost:4173)
npm run api
# Start both concurrently
npm run dev:full npm run dev:full
# Production build
npm run build
# Start production server (reads .env)
npm start
``` ```
Create a `.env` file at the project root with the required environment variables before running locally. The server uses `--env-file=.env`. `dev:full` starts both:
--- - Vite frontend (`http://localhost:5173`)
- Admin content API (`http://localhost:4173`)
## Docker / Deployment ## Build for production
Images are built and pushed to `ghcr.io/nmemmert/siteforge` automatically on every push to `main` or when a `v*` tag is pushed. The git commit SHA is baked into the image as `COMMIT_SHA` at build time and displayed in the admin sidebar. ```bash
npm run build
### First run ```
On first boot, `entrypoint.sh` copies seed data from the bundled `data-seed/` into `/app/data/` if that directory is empty. Subsequent restarts skip this step, so live data is never overwritten by an upgrade.
### docker-compose.yml ## Run in container
A `docker-compose.yml` is included at the project root. Mount `/app/data` to a persistent volume or host path and supply all required environment variables. Pull image directly:
```yaml ```bash
volumes: docker pull ghcr.io/nmemmert/siteforge:latest
- /path/to/your/data:/app/data docker run -d --name siteforge-app -p 4173:4173 -v ./data:/app/data ghcr.io/nmemmert/siteforge:latest
``` ```
### Useful commands Run with Docker Compose:
```bash ```bash
# Pull latest image and restart
docker compose pull docker compose pull
docker compose up -d docker compose up -d
```
# View logs Admin auth:
- Set `ADMIN_PASSWORD` on the server/container to protect `/admin` and admin stats/maintenance endpoints.
- Without `ADMIN_PASSWORD`, admin login is disabled until configured.
The app will be available at `http://localhost:4173`.
## Optional local LLM (Ollama) for grounded rewrites
You can keep deterministic retrieval as the source-of-truth and optionally rewrite responses with a local model.
1. Install and run Ollama on your host.
2. Pull a small model suited to older hardware, for example:
```bash
ollama pull qwen2.5:3b-instruct
```
3. Start the API with these environment variables:
```bash
CHATBOT_LLM_ENABLED=true
CHATBOT_LLM_BASE_URL=http://127.0.0.1:11434
CHATBOT_LLM_MODEL=qwen2.5:3b-instruct
CHATBOT_LLM_TIMEOUT_MS=25000
CHATBOT_LLM_NUM_CTX=2048
```
4. Call the rewrite endpoint from your existing chat flow:
`POST /api/chatbot-grounded-rewrite`
Request payload shape:
```json
{
"question": "Who was Titus?",
"draftAnswer": "Deterministic answer produced by current retrieval/synthesis.",
"sources": ["Episode 2 - Introduction to Titus"],
"contextChunks": [
{
"title": "Episode 2 - Introduction to Titus",
"sourceLabel": "Episode 2",
"content": "Titus was a Gentile..."
}
]
}
```
If the endpoint fails or is disabled, keep your deterministic answer and existing fallback behavior.
Persistent admin saves:
- Admin updates are written to `data/admin-content.json`.
- Built-in page hit stats are written to `data/hit-stats.json`.
- Detailed visitor analytics are written to `data/visitor-stats.json`.
- Backup snapshots are written to `data/backups/`.
- `docker-compose.yml` mounts `./data` into the container at `/app/data`.
- This keeps all admin-managed data after container restarts/rebuilds/updates.
## Where to edit content
- Project domain data and ownership: `src/data/projects.ts`
- Portfolio rendering logic: `src/App.tsx`
- Portfolio UI styles: `src/App.css`
- Global theme variables: `src/index.css`
## Routes
- Portfolio home: `/`
- Project details: `/projects/:slug`
- Admin editor: `/admin`
## Admin editing
- Open `/admin` to edit project card and featured-page content.
- Use **Global Settings** to update theme, home headers, and quick tips.
- Use **Project Settings** to update project-specific fields and README URL.
- Use **Create new entry** to add a new project directly from Admin.
- Enter a URL and click **Scan URL metadata** to auto-fill title, summary, domain, category, and icon when available.
- Click **Save project** to apply updates instantly.
- Saved edits are written to `data/admin-content.json` through the API server.
- Built-in stats in `/admin` include page hits plus visitor details (IP, country/state/county/city, returning visitors, and recent visitor log).
- Site Stats in `/admin` includes a Bible Questions inbox sourced from contact form submissions marked as Bible Question.
- Analytics cookies are consent-based. Visitors can accept or decline tracking from the site banner.
- Admin now includes maintenance actions: **Export JSON**, **Backup Now**, **Prune Old Data**, and **Clear Analytics**.
- Admin also supports restoring from a backup snapshot from `/admin`.
- The server creates startup + daily backup snapshots and retains recent backups automatically.
- Use the **Theme** dropdown to switch between Sandstone, Ocean, Midnight, Forest, and Sunset.
- Use **Remove project** to delete the selected project from your local Admin data.
- Use **Reset project** or **Reset all** to restore defaults from `src/data/projects.ts`.
Scan notes:
- URL scanning is best-effort and depends on site accessibility.
- Some sites block direct scanning; Admin falls back to a proxy scan path when possible.
- Always review scanned fields before saving.
Deployment note:
- To keep Admin saves working on the internet, deploy with the Node API (`server.js`) and writable server storage for `data/admin-content.json`.
Useful container commands:
```bash
docker compose logs -f docker compose logs -f
# Stop
docker compose down docker compose down
``` ```
--- Update to latest image:
## Persistent Data ```bash
docker compose pull
docker compose up -d
```
All server-managed data lives under `/app/data` (or `./data` locally): ## Notes
| Path | Contents | - The current project cards are sample placeholders.
|---|---| - Most metadata was scanned from live pages.
| `admin-content.json` | Published CMS content | - Skywatch currently uses a manual fallback description due to a `401` response during automated scan.
| `admin-content-draft.json` | Draft content (unpublished) |
| `hit-stats.json` | Page hit analytics |
| `visitor-stats.json` | Detailed visitor analytics |
| `contact-submissions.json` | Contact form submissions |
| `study-users.json` | Study account records |
| `study-reminders.json` | Scheduled study reminder state |
| `qr-codes.json` | QR code registry |
| `episode-scripts/` | Per-episode script files |
| `backups/` | Automatic JSON snapshot backups |
| `uploads/` | Admin-uploaded assets (served at `/uploads/*`) |
---
## Admin Access
1. Navigate to `/admin`
2. Enter the admin password (hashed value in `ADMIN_PASSWORD_HASH`)
3. Complete TOTP 2FA, or use an email OTP if no TOTP app is enrolled
4. On first use, enroll a TOTP authenticator app from Admin → Settings
Recovery codes are generated during TOTP setup and can be regenerated from Admin → Settings.
### Admin capabilities
- **Content** — edit and publish site content with draft/preview workflow
- **Episode scripts** — write and manage per-episode scripts; export to `.docx`
- **Podcast checklist** — track per-episode production steps
- **QR codes** — create, manage, and track scan counts for QR codes
- **Questions** — moderate submitted Q&A, pin answers, reply
- **Contact** — view submissions, reply with templates, check email health
- **Study users** — view enrollments, progress, certificates, manually enroll/unenroll
- **Study comments** — moderate community post comments
- **Subscribers** — view and export subscriber list
- **Assets** — upload and delete hosted images
- **Stats** — page hits, visitor geo analytics, download stats; export CSV
- **Backup** — snapshot now, restore from snapshot, export/import full `tar.gz`
- **Operations** — purge cache, trigger deploy, manage SEO/sitemap/redirects
---
## Versioning
The app version is read from `package.json` at startup (`APP_VERSION`). The git commit is baked in via `COMMIT_SHA` at Docker build time (set automatically by CI). Both are visible in the admin sidebar and at `GET /api/version`.
Bump `package.json` before every commit pushed to `main`: patch for fixes, minor for features, major for breaking changes.
-208
View File
@@ -1,208 +0,0 @@
/**
* Cloudflare Email Worker — versebyversewithnate.us
*
* Receives inbound emails to hello@ and nate@, forwards to Gmail,
* and POSTs a parsed copy to the site admin inbox.
*
* CLOUDFLARE SETUP:
* 1. Go to Email > Email Routing > Routes in your Cloudflare dashboard
* 2. Add two "Custom address" rules:
* hello@versebyversewithnate.us → Send to Worker → this worker
* nate@versebyversewithnate.us → Send to Worker → this worker
* 3. Make sure nmemmert@gmail.com is listed as a verified destination address
* (Email Routing > Destination addresses)
*
* WORKER ENVIRONMENT VARIABLES (Workers & Pages > this worker > Settings > Variables):
* GMAIL_FORWARD_ADDRESS = nmemmert@gmail.com
* WEBHOOK_URL = https://versebyversewithnate.us/api/inbound-email
* WEBHOOK_SECRET = <a long random string — must match INBOUND_EMAIL_SECRET on the server>
*
* SERVER ENVIRONMENT VARIABLE (set in your server host / .env):
* INBOUND_EMAIL_SECRET = <same long random string as WEBHOOK_SECRET above>
*/
export default {
async email(message, env, ctx) {
const forwardPromise = message.forward(env.GMAIL_FORWARD_ADDRESS)
const rawEmail = await streamToText(message.raw)
const parsed = parseEmail(rawEmail, message)
const webhookPromise = fetch(env.WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Secret': env.WEBHOOK_SECRET,
},
body: JSON.stringify(parsed),
}).catch(err => console.error('[email-worker] webhook failed:', err.message))
await Promise.all([forwardPromise, webhookPromise])
},
}
async function streamToText(stream) {
const reader = stream.getReader()
const chunks = []
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
}
const bytes = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0))
let offset = 0
for (const chunk of chunks) {
bytes.set(chunk, offset)
offset += chunk.length
}
return new TextDecoder().decode(bytes)
}
function parseEmail(raw, message) {
const { headers, body } = splitHeadersAndBody(raw)
const subject = decodeHeaderValue(headers['subject'] ?? '(no subject)')
const from = message.from ?? headers['from'] ?? ''
const to = message.to ?? headers['to'] ?? ''
const date = headers['date'] ?? new Date().toISOString()
const messageId = headers['message-id'] ?? ''
const { plainText, htmlBody } = extractBodies(headers['content-type'] ?? '', body)
return {
from,
to,
subject,
body: plainText,
htmlBody: htmlBody || null,
date,
messageId,
source: 'inbound-email',
}
}
// Splits an RFC 5322 message (or MIME part) into its unfolded header map and raw body string.
function splitHeadersAndBody(raw) {
const match = /\r?\n\r?\n/.exec(raw)
const headerBlock = match ? raw.slice(0, match.index) : raw
const body = match ? raw.slice(match.index + match[0].length) : ''
// RFC 2822 header folding: a line starting with SP/TAB continues the previous header line.
const unfolded = headerBlock.replace(/\r?\n[ \t]+/g, ' ')
const headers = {}
for (const line of unfolded.split(/\r?\n/)) {
const colon = line.indexOf(':')
if (colon <= 0) continue
const key = line.slice(0, colon).trim().toLowerCase()
const val = line.slice(colon + 1).trim()
if (!headers[key]) headers[key] = val
}
return { headers, body }
}
// Recursively walks a (possibly multipart) MIME body and returns the first text/plain and text/html parts found.
function extractBodies(topContentType, topBody) {
let plainText = null
let htmlBody = null
function visit(contentType, body, transferEncoding) {
const type = (contentType.split(';')[0] || 'text/plain').trim().toLowerCase()
if (type.startsWith('multipart/')) {
const boundary = getBoundary(contentType)
if (!boundary) return
for (const part of splitOnBoundary(body, boundary)) {
const { headers, body: partBody } = splitHeadersAndBody(part)
visit(headers['content-type'] ?? 'text/plain', partBody, headers['content-transfer-encoding'] ?? '')
}
return
}
const decoded = decodeBody(body, transferEncoding)
if (type === 'text/html' && htmlBody === null) htmlBody = decoded.trim()
if (type === 'text/plain' && plainText === null) plainText = decoded.trim()
}
visit(topContentType || 'text/plain', topBody, '')
if (plainText === null && htmlBody !== null) {
plainText = htmlBody.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/\s{3,}/g, '\n\n').trim()
}
if (plainText === null) plainText = ''
return { plainText, htmlBody }
}
function getBoundary(contentType) {
const match = /boundary\s*=\s*"([^"]+)"|boundary\s*=\s*([^;\s]+)/i.exec(contentType)
if (!match) return null
return match[1] ?? match[2]
}
// Splits a multipart body on its boundary markers, ignoring the preamble/epilogue.
function splitOnBoundary(body, boundary) {
const escaped = boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const re = new RegExp(`(?:^|\\r?\\n)--${escaped}(--)?(?:\\r?\\n|$)`, 'g')
const parts = []
let lastIndex = 0
let started = false
let match
while ((match = re.exec(body)) !== null) {
if (started) parts.push(body.slice(lastIndex, match.index))
started = true
lastIndex = match.index + match[0].length
if (match[1]) break // final boundary: "--boundary--"
}
return parts
}
function decodeBody(text, transferEncoding) {
const enc = (transferEncoding || '').trim().toLowerCase()
if (enc === 'base64') {
try {
const binary = atob(text.replace(/\s+/g, ''))
return new TextDecoder('utf-8').decode(Uint8Array.from(binary, c => c.charCodeAt(0)))
} catch {
return ''
}
}
if (enc === 'quoted-printable') {
return decodeEmailBody(text)
}
return text
}
function decodeEmailBody(text) {
// Handle quoted-printable encoding (=XX hex sequences and soft line breaks).
// Decode into raw bytes first, then run through TextDecoder so multi-byte
// UTF-8 sequences split across multiple =XX escapes recombine correctly.
const unfolded = text.replace(/=\r?\n/g, '')
const bytes = []
for (let i = 0; i < unfolded.length; i++) {
if (unfolded[i] === '=' && /^[0-9A-Fa-f]{2}$/.test(unfolded.slice(i + 1, i + 3))) {
bytes.push(parseInt(unfolded.slice(i + 1, i + 3), 16))
i += 2
} else {
bytes.push(unfolded.charCodeAt(i))
}
}
return new TextDecoder('utf-8').decode(Uint8Array.from(bytes))
}
function decodeHeaderValue(value) {
// Handle encoded words: =?UTF-8?B?...?= or =?UTF-8?Q?...?=
return value.replace(/=\?([^?]+)\?([BQ])\?([^?]*)\?=/gi, (_, charset, encoding, encoded) => {
try {
if (encoding.toUpperCase() === 'B') {
const binary = atob(encoded)
return new TextDecoder(charset).decode(Uint8Array.from(binary, c => c.charCodeAt(0)))
}
if (encoding.toUpperCase() === 'Q') {
return encoded.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
}
} catch {
return encoded
}
return encoded
})
}
File diff suppressed because it is too large. Load diff
+6 -1298
View File
File diff suppressed because it is too large. Load diff
+745
View File
@@ -0,0 +1,745 @@
[
{
"id": "cb_001",
"type": "qa",
"title": "What Bible translation do you use?",
"content": "In the podcast, I primarily teach from the BSB (Berean Standard Bible). I may compare other translations at times, but BSB is my main teaching text in the Verse by Verse episodes.",
"keywords": [
"bible",
"translation",
"bsb",
"berean standard bible",
"version",
"podcast",
"teaching"
],
"createdAt": "2026-04-13T00:00:00.000Z",
"updatedAt": "2026-04-13T14:06:04.272Z"
},
{
"id": "cb_002",
"type": "qa",
"title": "How do you approach a difficult or confusing Bible passage?",
"content": "I always start by reading the surrounding context — a confusing verse often makes perfect sense once you zoom out a chapter or two. Then I look at who the author was, who they were writing to, and what was happening historically. A good study Bible or a commentary like Matthew Henry's can be a huge help. Most importantly, pray for understanding before you dive in. The Holy Spirit is your best guide.",
"keywords": [
"difficult",
"confusing",
"passage",
"context",
"commentary",
"study",
"interpretation",
"hard"
],
"createdAt": "2026-04-13T00:00:00.000Z",
"updatedAt": "2026-04-13T14:06:04.272Z"
},
{
"id": "cb_003",
"type": "qa",
"title": "How do I stay consistent with daily Bible reading?",
"content": "Start small and protect that time like an appointment. Even 10 minutes in the morning before checking your phone can be transformative over a year. A reading plan helps a lot — there are great ones on apps like YouVersion that break the whole Bible into daily chunks so you never have to decide what to read. And give yourself grace on the days you miss; guilt is not a good motivator. Just pick back up where you left off.",
"keywords": [
"daily",
"reading",
"consistent",
"habit",
"bible",
"plan",
"youversion",
"devotional",
"routine",
"morning"
],
"createdAt": "2026-04-13T00:00:00.000Z",
"updatedAt": "2026-04-13T14:06:04.272Z"
},
{
"id": "cb_005",
"type": "topic",
"title": "About the Verse by Verse with Nate Podcast",
"content": "Verse by Verse with Nate is a podcast dedicated to walking through books of the Bible chapter by chapter and verse by verse. The goal is to help everyday listeners develop a deep, contextual understanding of Scripture — not just surface-level summaries, but the meaning behind the words. New episodes are released regularly. You can listen on Spotify, Apple Podcasts, and other major platforms.",
"keywords": [
"podcast",
"about",
"verse by verse",
"nate",
"listen",
"show",
"episodes",
"what is"
],
"createdAt": "2026-04-13T00:00:00.000Z",
"updatedAt": "2026-04-13T14:06:04.272Z"
},
{
"id": "cb_006",
"type": "topic",
"title": "How to listen to the podcast",
"content": "You can listen to Verse by Verse with Nate on Spotify or Apple Podcasts. Just search 'Verse by Verse with Nate' in your favorite podcast app. New episodes drop regularly — subscribing will make sure you never miss one. If you'd like to leave a review, that helps more people find the show!",
"keywords": [
"listen",
"subscribe",
"spotify",
"apple podcasts",
"find",
"where",
"app",
"review"
],
"createdAt": "2026-04-13T00:00:00.000Z",
"updatedAt": "2026-04-13T14:06:04.272Z"
},
{
"id": "cb_007",
"type": "qa",
"title": "How do I submit a question to Nate?",
"content": "You can submit a question using the contact form at the bottom of this page. Select 'Bible Question' as the message type. Nate reads every question personally and answers the best ones on the site's Q&A section or in a future episode. There's no guarantee every question gets a public response, but all are read and appreciated!",
"keywords": [
"submit",
"question",
"contact",
"ask",
"send",
"form",
"email"
],
"createdAt": "2026-04-13T00:00:00.000Z",
"updatedAt": "2026-04-13T14:06:04.272Z"
},
{
"id": "cb_008",
"type": "topic",
"title": "Prayer and quiet time",
"content": "Prayer is simply talking to God — it doesn't have to be formal or long. A quiet time usually involves reading a passage of Scripture, reflecting on what it means for your life today, and spending a few minutes in prayer. You must make sure that you stay in context of what Scripture says and not put your own thoughts or words into what the Bible is saying. Many people find journaling helpful. The key is consistency, not perfection. Even five minutes a day done faithfully is far better than an hour once a week.",
"keywords": [
"prayer",
"quiet time",
"devotional",
"pray",
"journal",
"meditate",
"reflect",
"morning",
"time with god"
],
"createdAt": "2026-04-13T00:00:00.000Z",
"updatedAt": "2026-04-13T14:06:04.272Z"
},
{
"id": "cb_faith_family_001",
"type": "qa",
"title": "How can I share my faith with skeptical family?",
"content": "Lead with relationship, not argument. Skeptical family members usually don't need more information — they need to see the faith lived out genuinely. Consistency, love, and patience over time speaks louder than any debate win.\n\nPray specifically and persistently. This is the most underrated strategy. God moves hearts in ways arguments never can.",
"keywords": [
"faith",
"family",
"skeptical",
"share",
"relationship",
"evangelism",
"prayer",
"witness",
"unbeliever",
"argument",
"patience",
"love"
],
"createdAt": "2026-04-13T00:00:00.000Z",
"updatedAt": "2026-04-13T00:00:00.000Z"
},
{
"id": "cb_blessed_hope_001",
"type": "qa",
"title": "What is the blessed hope?",
"content": "The blessed hope is a phrase from Titus 2:13 — \"as we await the blessed hope and glorious appearance of our great God and Savior Jesus Christ.\" In Episode 9, Nate breaks down the Greek: makaria elpis. Makaria is the same word used in the Beatitudes — a divinely favored condition. And elpis in the New Testament is not wishful thinking. Biblical hope is a confident expectation of something certain. Not a wish. An anchor.\n\nThe blessed hope is simply this: Jesus is coming back. Not maybe. He is coming back. And the person who really believes that is in a blessed condition even now, living toward a certainty that changes how everything feels today.",
"keywords": [
"blessed hope",
"titus 2:13",
"second coming",
"return of Christ",
"hope",
"appearing",
"makaria",
"elpis",
"anchor",
"future",
"episode 9"
],
"createdAt": "2026-04-13T00:00:00.000Z",
"updatedAt": "2026-04-13T00:00:00.000Z"
},
{
"id": "cb_who_is_titus_001",
"type": "qa",
"title": "Who was Titus?",
"content": "Titus was a Gentile — not Jewish — who came to faith through Paul's ministry. Paul calls him \"my true child in our common faith\" (Titus 1:4). He was one of Paul's closest and most trusted co-workers. In 2 Corinthians 7, Paul describes being comforted by Titus arriving during a difficult season. In chapter 8, Titus helped organize a relief offering for believers in Jerusalem. In Galatians 2, he appears as proof that the gospel is for everyone.\n\nPaul left Titus on the island of Crete with a difficult assignment: appoint elders, correct false teaching, and help a young church find its footing in a challenging culture. The letter of Titus is essentially his briefing.",
"keywords": [
"titus",
"who is titus",
"gentile",
"paul",
"co-worker",
"crete",
"new testament",
"biography",
"history",
"faith"
],
"createdAt": "2026-04-13T00:00:00.000Z",
"updatedAt": "2026-04-13T00:00:00.000Z"
},
{
"id": "cb_deny_works_001",
"type": "qa",
"title": "What does it mean to deny God by your works?",
"content": "Titus 1:16 says: \"They profess to know God, but by their actions they deny Him. They are detestable, disobedient, and unfit for any good deed.\"\n\nIn Episode 6, Nate explains this using three precise words Paul chose. Detestable — morally repulsive before God, regardless of religious appearance. Disobedient — not persuadable, resistant to truth even when confronted with it. Unfit — a Greek metalworking term for metal tested and found counterfeit. Religious on the surface, but no real substance underneath.\n\nThe warning cuts both ways: a person can know the right words, attend the right services, hold the right opinions — and still live in a way that functionally says God doesn't matter. Theology and life are inseparable. What you actually believe will show itself in how you actually live.",
"keywords": [
"deny god",
"works",
"titus 1:16",
"detestable",
"disobedient",
"unfit",
"profess",
"actions",
"hypocrisy",
"false teaching",
"episode 6"
],
"createdAt": "2026-04-13T00:00:00.000Z",
"updatedAt": "2026-04-13T00:00:00.000Z"
},
{
"id": "cb_grace_trains_001",
"type": "qa",
"title": "What does grace actually train us to do?",
"content": "Titus 2:11-12 says grace \"instructs us to renounce ungodliness and worldly passions, and to live sensible, upright, and godly lives.\"\n\nIn Episode 8, Nate points out that the Greek word for instructs is paideuō — the education of a child. Not classroom instruction but formation. Grace is the subject doing the teaching.\n\nGrace teaches two things: what to say no to (ungodliness and worldly passions) and what to say yes to — sensible (an ordered mind), upright (right in relationship with others), and godly (God genuinely at the center). Inward, outward, and upward.\n\nThe key insight: the pathway to saying no to sin is going deeper into grace, not trying harder. Grace properly understood reorients your desires — it doesn't just change the rules, it changes what you want. Grace is not a hall pass. It's a school you attend every day.",
"keywords": [
"grace",
"trains",
"titus 2:11",
"titus 2:12",
"paideuō",
"ungodliness",
"sensible",
"upright",
"godly",
"obedience",
"formation",
"episode 8"
],
"createdAt": "2026-04-13T00:00:00.000Z",
"updatedAt": "2026-04-13T00:00:00.000Z"
},
{
"id": "cb_leadership_titus1_001",
"type": "qa",
"title": "What does Titus 1 teach about church leadership?",
"content": "Paul's first instruction in Titus was to appoint elders — because a church without mature, qualified leadership is vulnerable. From Episode 4 (Titus 1:5-9):\n\nThe elder's home is exhibit A. Before looking at preaching or public presence, look at the household. If a man cannot lead his own family well, there's no reason to believe he'll lead God's family well. Integrity can't be compartmentalized.\n\nPaul gives five things an elder must NOT be: not self-absorbed, not quick-tempered, not given to drunkenness, not violent, not greedy. And six things he must BE: hospitable, a lover of good, self-controlled, upright, holy, and disciplined in sound doctrine.\n\nThis is not a job description for a CEO. Nate frames it as a picture of Christlike character — authority that flows from below, from a posture of submission and service, not dominance.",
"keywords": [
"leadership",
"elder",
"overseer",
"titus 1:5",
"titus 1:6",
"titus 1:7",
"titus 1:9",
"church",
"pastor",
"qualify",
"character",
"household",
"episode 4"
],
"createdAt": "2026-04-13T00:00:00.000Z",
"updatedAt": "2026-04-13T00:00:00.000Z"
},
{
"id": "cb_titus347_summary_001",
"type": "qa",
"title": "Give me a summary of Titus 3:4-7",
"content": "Titus 3:4-7 is what Nate calls 'the gospel in one paragraph' — one of the most concentrated summaries of salvation anywhere in Paul's letters. Episode 12 breaks it down:\n\nAll three persons of the Trinity are present: God the Father is the initiating motivation — He is kind and full of love toward humanity. The Holy Spirit is the agent of new birth and ongoing renewal. Jesus Christ is the one through whom the Spirit is poured out, and verse 7 calls him 'our Savior.'\n\nThe hinge of the passage is verse 5: He saved us not by the righteous deeds we had done, but according to His mercy. Not partially. Not mostly. The basis is His mercy, not our merit — which means salvation rests on something that cannot change.\n\nVerse 7 gives us two results: justified (declared righteous in God's courtroom) and heirs (members of the family with a guaranteed inheritance). You are not just legally acquitted — you belong.",
"keywords": [
"titus 3:4",
"titus 3:4-7",
"titus 3:7",
"summary",
"gospel",
"mercy",
"salvation",
"trinity",
"justified",
"heirs",
"new birth",
"renewal",
"holy spirit",
"episode 12"
],
"createdAt": "2026-04-13T00:00:00.000Z",
"updatedAt": "2026-04-13T00:00:00.000Z"
},
{
"id": "cb_christians_politics_001",
"type": "qa",
"title": "How should Christians engage with politics and public life?",
"content": "Titus 3:1-2 is Paul's answer, covered in Episode 10. He tells Titus to remind believers to submit to rulers and authorities, be obedient, be ready for every good work, malign no one, and be peaceable, gentle, and considerate to everyone.\n\nNate's key insight: Paul is not saying agree with every government decision. He's saying your engagement with public life should be marked by something recognizable as different. Submission, readiness to do good, and a peaceable presence are not just civic virtues — they are missionary strategies. The way you navigate public life either opens doors for the gospel or closes them.\n\nThe word for peaceable in Greek is amachous — literally non-combative. Paul doesn't say malign no one except your political opponents. He says no one. This is not weakness. It is the confidence of people who know how the story ends and don't need to win every argument to prove it.",
"keywords": [
"politics",
"government",
"Christians",
"public life",
"civic",
"titus 3:1",
"titus 3:2",
"submit",
"peaceable",
"gentle",
"social media",
"culture",
"episode 10"
],
"createdAt": "2026-04-13T00:00:00.000Z",
"updatedAt": "2026-04-13T00:00:00.000Z"
},
{
"id": "167fa083-5438-44a5-ac72-6dd379e937f5",
"type": "episode",
"title": "Episode 2 — Introduction to Titus (Background & Overview)",
"content": "EPISODE 2 — Introduction to Titus Background & Overview of the Letter · 10–12 minutes ✝ OPENING PRAYER [Pause before opening the text — invite God into the study] Father, as we begin this study of Titus, open our eyes to the world behind this letter. Give us hearts ready to receive Your Word — not just as history, but as living truth for today. In Jesus’ name, Amen. 🎙️ COLD OPEN [Warm, unhurried tone — like you’re settling in with a friend] Before we read a single verse of this letter — before we meet an elder, before we encounter a false teacher, before we hear a single word of instruction — we need to stop and ask a question that most people skip over. Who is writing this? Who is reading it? And WHAT in the world is going on? Because here’s the thing about the Bible: it wasn’t written in a vacuum. Every letter, every word, was written to a real person in a real place dealing with real problems. And when we understand that — when we step into the world behind the text — the words stop being distant history and start becoming something alive. So today, on Episode 2 of Verse by Verse with Nate, we’re doing exactly that. We’re going to discover who wrote the letter of Titus, who received it, where they were, and why this letter even exists. Let’s get into it. 📖 SEGMENT 1 — WHO WAS PAUL? [Confident, conversational — you know this material] Let’s start at the beginning. The letter opens in verse 1 with the words: Paul, a servant of God and an apostle of Jesus Christ for the faith of God’s elect and their knowledge of the truth that leads to godliness, Now, if you’ve spent any time in the New Testament, you’ve heard of Paul. But let me give you the short version, because I think it matters here. Paul — originally named Saul — was a man who, by his own description, was a Hebrew of Hebrews. He was a Pharisee. He was educated, zealous, and deeply committed to the law of Moses. And for a season of his life, that zeal led him to do something he would carry with him for the rest of his days: he persecuted the early church. He approved of the stoning of Stephen. He dragged men and women out of their homes and had them thrown into prison. And then — on a road to Damascus — everything changed. He met the risen Jesus. And the man who was once the church’s greatest enemy became its most tireless ambassador. Paul went on to plant churches across the Roman world. He traveled through modern-day Turkey, Greece, Macedonia. He wrote letters that would become a third of our New Testament. He was beaten, shipwrecked, imprisoned — and through all of it, he kept going. Why does this matter for Titus? Because the man writing this letter is not writing from a place of comfort or theory. Paul understood, in the most personal way possible, what it meant to be completely transformed by grace. That’s going to come through on every page. 📖 SEGMENT 2 — WHO WAS TITUS? [A little warmer here — Titus is someone Paul clearly loved] Now, who is Titus? Titus doesn’t get as much attention as, say, Timothy — but he should. He’s one of Paul’s closest and most trusted co-workers, and the more you look at him, the more impressive he becomes. Here’s what we know: Titus was a Gentile. He wasn’t Jewish. He came to faith through Paul’s ministry — which is why Paul, in verse 4 of this very letter, calls him: To Titus, my true child in our common faith: Grace and peace from God the Father and Christ Jesus our Savior. That phrase is loaded. Paul is saying: you and I share the same gospel. You are my son in the faith. If you want to understand Titus better, look at a few other places in Paul’s letters. In 2 Corinthians chapter 7, Paul describes being comforted by the arrival of Titus during a particularly difficult season. In chapter 8, he sends Titus to help organize a relief offering for struggling believers in Jerusalem. And in Galatians chapter 2, Titus shows up as something of a test case — proof, in human form, that the gospel is for everyone. This is a man Paul trusted with hard things. And that’s important — because what he’s being asked to do in Crete is very hard. 📖 SEGMENT 3 — WHERE IS CRETE? [Geographic and cultural — paint a picture] Which brings us to the island. Crete. If you look at a map of the Mediterranean, you’ll find it in the southern Aegean Sea — a large island stretching about 160 miles from east to west. It was a well-known place in the ancient world. It had a long history, a busy coastline, and a reputation. And not necessarily a good one. Paul actually quotes a Cretan poet in this very letter — we’ll get to that in chapter 1. Crete had a reputation for dishonesty, for moral looseness, for people who were, frankly, difficult to work with. The cultural environment was a challenging one for a young church trying to take root. There were also false teachers mixing Jewish influences with the faith in unhealLine truncated
"keywords": [
"titus",
"episode 2",
"titus 2:11",
"introduction",
"background",
"overview",
"crete",
"letter",
"before",
"who",
"verse",
"episode",
"god",
"world",
"word",
"real",
"opening",
"text",
"study",
"open",
"behind",
"history",
"truth"
],
"createdAt": "2026-04-13T13:11:07.315Z",
"updatedAt": "2026-04-14T17:22:04.903Z"
},
{
"id": "881279c7-3ca4-436d-a584-19cc0a5a9bf3",
"type": "episode",
"title": "Episode 3 — Who Is Paul, and Why Does This Letter Exist? (Titus 1:1– 4)",
"content": "EPISODE 3 — Who Is Paul, and Why Does This Letter Exist? Titus 1:1–4 · 10–12 minutes ✝ OPENING PRAYER [Pause before opening the text — invite God into the study] Let’s Pray as we get started…. Father, your Word carries weight, as we study the opening few verses in Titus I pray that our minds and hearts will be open to receive what you have for us. Let the truth that You cannot lie be more than then something we read or know — let it be an anchor for us as we study Your Word. In Jesus’ name I pray, Amen. 🎙️ COLD OPEN [Thoughtful, a little searching — draw them in before you open the text] Have you ever received a letter that changed everything? Maybe it was an acceptance letter. A medical diagnosis. A note from someone you loved that arrived too late — or just in time. Some letters carry more weight than their paper deserves. You hold them and feel the gravity before you’ve even finished the first line. Paul’s letter to Titus begins with what looks, at first glance, like a standard ancient greeting. But this one is different. Before Paul even gets to Titus’s name, he writes four verses of dense, loaded, theologically rich introduction. Every phrase is doing work. Every word is carrying freight. So today on Episode 3 of Verse by Verse with Nate, we’re going to slow down and take that opening apart — word by word, phrase by phrase. We’re in Titus 1, verses 1 through 4. Four verses. One sentence in the original Greek. An entire theology compressed into a greeting. Let’s unpack it. 📖 SEGMENT 1 — SERVANT AND APOSTLE [Deliberate pace — these two words are doing more than people realize] Let’s start by reading Titus Chapter 1 Verses 1-4 I am reading from The Berean Standard Bible (BSB). 1Paul, a servant of God and an apostle of Jesus Christ for the faith of God’s elect and their knowledge of the truth that leads to godliness, 2in the hope of eternal life, which God, who cannot lie, promised before time began. 3In His own time He has made His word evident in the proclamation entrusted to me by the command of God our Savior. 4To Titus, my true child in our common faith: Grace and peace from God the Father and Christ Jesus our Savior. Paul opens with two words to describe himself: servant and apostle. The order matters, and the pairing is intentional. Apostle is a word of authority — a commissioned representative sent with the full weight of the sender’s backing behind them. Paul is saying: I speak on behalf of Jesus Christ. What I write carries weight. But before he says apostle, he says servant. In the Greek, the word is doulos (doo'-los) — bondservant – a person who chooses to be a servant one who belongs entirely to another. Paul is saying: before anything else, I am owned. Everything I do flows from submission to the God I serve. This combination — servant and apostle — is a picture of what healthy spiritual authority looks like. It is never raw power. It is authority that comes from below — from a posture of submission — exercised in service, not dominance. Paul is modeling for Titus, right here in the opening line, what it looks like to lead. You don’t choose between humility and authority. You hold both. At the same time. 📖 SEGMENT 2 — TRUTH THAT LEADS TO GODLINESS [This is the theme of the whole letter — set it up clearly] In verse 1, Paul describes his mission as being for “the faith of God’s elect and their knowledge of the truth that leads to godliness.” That last phrase is the key: truth that leads to godliness. Paul is not interested in truth for its own sake — collecting correct theological propositions the way some people collect rare books, impressive on the shelf but never actually read. For Paul, truth has a direction. Truth moves. Truth produces something. What does it produce? Godliness. A life shaped by what it believes. This is the big theme of the entire letter — sound doctrine and transformed living are not separate categories. They are inseparable. The truth you know should be shaping the way you live. If it isn’t — if your theology is just mental furniture — something has gone wrong. 📖 SEGMENT 3 — BEFORE TIME BEGAN [Slow down here — this is a big idea, give it room] Now we get to one of the most stunning phrases in the entire letter. Verse 2: …in the hope of eternal life, which God, who cannot lie, promised before time began. Before time began. Let that land for a second. Paul is saying that the plan of salvation was not an afterthought. It was not Plan B. It was not God improvising a rescue after the fall. It was a promise made before the universe existed. Before the first atom, the first star, the first breath — God had already determined to save a people for Himself. And notice what Paul says about the God who made that promise: He cannot lie. Not He does not lie. He cannot lie. It is outside His nature. It is impossible. That means the promise of eternal life is not hLine truncated
"keywords": [
"titus",
"episode 3",
"titus 1:1– 4",
"titus 1:1",
"paul",
"does",
"this",
"letter",
"exist",
"word",
"opening",
"before",
"let",
"verses",
"episode",
"study",
"pray",
"open",
"more",
"phrase",
"who",
"1:1",
"text",
"weight"
],
"createdAt": "2026-04-13T13:11:07.347Z",
"updatedAt": "2026-04-14T17:22:04.903Z"
},
{
"id": "e0ad268f-2826-41a5-b078-90f90aeda21b",
"type": "episode",
"title": "Episode 4 — What Does a Faithful Leader Look Like? (Titus 1:5–9)",
"content": "EPISODE 4 — What Does a Faithful Leader Look Like? Titus 1:5–9 · 12–14 minutes ✝ OPENING PRAYER [Pause before opening the text — invite God into the study] Father, as we open this passage on leadership, search us first. Show us where You are calling us to lead with character, not just ability. Give us humility to receive what Your Word says — and courage to live it. In Jesus’ name, Amen. 🎙️ COLD OPEN [Confident, a little provocative — challenge the room from the first sentence] We live in an age obsessed with talent, charisma, and results. Think about how we choose leaders. In business, we promote the highest performer. In politics, we vote for the most compelling speaker. In entertainment, we follow whoever has the most followers. We are drawn to gifted people. We reward ability. We celebrate outcomes. And there’s nothing automatically wrong with competence. But Paul’s blueprint for church leadership looks nothing like what the world values. When he tells Titus who to appoint as an elder, he doesn’t say: find the most dynamic preacher. He talks about character. He talks about household. He talks about self-control and hospitality and whether a man is quick-tempered. Which raises a question worth sitting with: why does God define fitness for leadership not by what a person can do, but by who a person is? Today on Episode 4, we’re in Titus 1, verses 5 through 9 — and we’re going to find out. 📖 SEGMENT 1 — WHY ELDERS FIRST? [Set the context — help listeners feel the logic of Paul’s priorities] Remember where we are in the letter. Paul has just finished his opening and the very first practical instruction he gives Titus is this: The reason I left you in Crete was that you would set in order what was unfinished and appoint elders in every town, as I directed you. Appoint elders. That’s the first thing. Because a church without mature, qualified leadership is vulnerable. Think of it this way: Paul had planted the gospel in Crete, and people had come to faith. But a collection of new believers without shepherds is like a flock without a shepherd — it can survive for a season, but it will eventually scatter or get picked off. Leadership is not bureaucracy. In Paul’s vision, it’s protection. It’s structure that allows the gospel to take root across generations. Titus’s first assignment is to build the infrastructure that makes everything else possible. 📖 SEGMENT 2 — VERSE 6: THE HOUSEHOLD QUALIFICATION [Let this land slowly — most people skip over it; make them feel the weight of it] Before Paul moves to the broader character portrait in verses 7 and 8, he pauses in verse 6 to say something about the elder’s household. It’s easy to read past it. Don’t. An elder must be blameless, the husband of but one wife, having children who are believers and who are not open to accusation of indiscretion or insubordination. The elder’s home is exhibit A. His marriage, his children, the atmosphere of his household — these are not private matters separate from his fitness to lead. They are the primary evidence of it. Think about what Paul is saying. Before Titus looks at a man’s preaching or his public presence, he should look at his home. Is his marriage marked by faithfulness? Are his children — the people who know him best, who cannot be impressed or deceived — living in a way that reflects what he believes? Paul’s logic is plain: if a man cannot lead his own household well, there is no reason to believe he will lead God’s household well. The church is not a stage where a person performs. It is a family — and family life requires the same qualities at home and at the pulpit. Integrity cannot be compart-mentalized. This isn’t a demand for perfection — Paul is not saying an elder must have flawless children. He says they must not be open to accusation of wild living or insubordination. The standard is a household genuinely ordered by the same faith the elder proclaims. 📖 SEGMENT 3 — ELDER AND OVERSEER: TWO WORDS, ONE ROLE [Brief word study — keep it accessible, not academic] In verses 5 through 7, Paul uses two words interchangeably: elder and overseer. Elder — presbyteros — carries the idea of maturity and respect. It speaks to someone the community looks to for wisdom. Overseer — episkopos — is more functional. It means one who watches over, who takes responsibility for the welfare of others. It emphasizes the work of the role. Together they give us the full picture. The person leading a local church is someone who is mature in faith AND active in oversight. Not just respected but responsible. Not just experienced but engaged. 📖 SEGMENT 4 — THE FIVE ‘NOTS’ AND THE POSITIVES [Take your time here — let each quality land before moving on] In verses 7 and 8, Paul gives Titus a remarkably specific list. Let’s read them: As God’s steward, an overseer must be above reproach—not self-absorbed, not quLine truncated
"keywords": [
"titus",
"episode 4",
"titus 1:5–9",
"titus 1:5",
"what",
"does",
"faithful",
"leader",
"look",
"like",
"episode",
"opening",
"leadership",
"first",
"most",
"paul",
"talks",
"1:5",
"god",
"open",
"character",
"ability",
"live",
"nothing"
],
"createdAt": "2026-04-13T13:11:07.369Z",
"updatedAt": "2026-04-14T17:22:04.903Z"
},
{
"id": "56626549-7e1e-4564-8c79-2d21b49eb911",
"type": "episode",
"title": "Episode 5 — The Danger of Empty Words (Titus 1:10–13a)",
"content": "EPISODE 5 — The Danger of Empty Words Titus 1:10–13a · 16–18 minutes ✝ OPENING PRAYER [Pause before opening the text — invite God into the study] Father, as we continue in the study of Titus, give us ears to recognize voices that distort Your Word — and the courage, Father, to name them. Guard our hearts and minds so that we are not led astray by persuasive words that sound wise but are empty of Your truth. Give us discernment to test every teaching against the light of Scripture, and humility to remain teachable before You. Help us also Father, to love truth more than comfort, and obedience more than approval. When confusion surrounds us Father, anchor us firmly in Your Word that never changes. When deception appears attractive, remind us that only Your truth brings life and freedom. Protect us from empty words, from teachings that twist the gospel, and from anything that would pull our hearts away from you. Strengthen us to speak truth with grace, boldness, and love. Keep our minds clear, our spirits grounded, and our faith rooted deeply in the truth that sets us free. We trust You to guide us, correct us, and keep us faithful. In Jesus’ name I pray, Amen. 🎙️ COLD OPEN [Urgent, grounded — the threat is real, don’t soften it] Paul doesn’t spend long on the ideal before he addresses the threat. He’s just finished describing what a faithful elder looks like — and before that picture has even settled, he turns the corner and says: because here’s what you’re up against. False teachers were already at work in Crete when Titus arrived. They weren’t on the horizon — they were already inside the churches, teaching, doing damage. Paul calls them rebellious, full of empty talk, deceivers. And their damage was real: entire households being undermined. Today on Episode 5, we’re going to look at who these teachers were, what made their teaching so dangerous, and why Paul’s response — which feels harsh at first — is actually an act of love. We’re in Titus 1, verses 10 through 13a. 📖 SEGMENT 1 — WHO WERE THE CIRCUMCISION GROUP? [Brief historical grounding — help listeners understand the specific threat] Paul identifies the primary troublemakers in verse 10: For many are rebellious and full of empty talk and deception, especially those of the circumcision, “Those of the circumcision” refers to some of the Jewish Christians — or people who were influenced by Jewish tradition — they were insisting that Gentile believers needed to follow the Mosaic law in addition to having faith in Christ. The specific issue was circumcision — but it stood for a whole system. The argument was essentially: Jesus is necessary, but not sufficient. You also need the law, Jewish customs, certain food regulations, and certain rituals. For Paul, this was not a secondary debate. It was a direct attack on the gospel itself. The moment you add requirements to grace, you have changed what grace is. Paul’s entire ministry was grounded in one truth: salvation comes through Christ alone. Jesus’ life, death, and resurrection are fully sufficient. Nothing needs to be added. Faith in Jesus is all that is required to be reconciled to God. 📖 SEGMENT 2 — THREE GREEK WORDS [Word study — keep it vivid, help the words come alive] In verse 10, Paul uses three Greek words to describe these teachers, and each one is precise. Rebellious. [DO NOT READ: 🇬🇷 ανυπότακτος (any-potak-tos)] The root meaning is “not placed under.” It’s the same word used elsewhere in the New Testament for children who refuse to submit to parents, or citizens who reject governing authority. What Paul is describing here isn’t someone who simply disagrees with church leadership — it’s someone who has fundamentally decided they don’t answer to anyone. No elder. No apostle. No Scripture. They’ve appointed themselves the final authority over their own teaching. Think of a contractor who builds whatever the client wants — no permits, no inspections, no code. The work looks fine on the surface. But there’s no accountability holding it together. When the storm comes, the structure fails. That’s what unaccountable teaching does to a church. It looks fine — until it doesn’t. Full of empty talk. [DO NOT READ: 🇬🇷 ματαιολόγος (mata-io-logos)] This word is built from two pieces: the word for “empty” or “vain” — the same word used in the Septuagint for idols, things that look substantial but have nothing in them — combined with the word for “word” or “speech.” Paul is saying these teachers produce words that are structurally hollow. The form is there. The volume is there. But when you press on it, there’s nothing holding the weight. It’s religious vocabulary without biblical substance. You’ve probably heard it — teaching that sounds rich, that uses all the right words, that references God and grace and the Spirit. But when you go Line truncated
"keywords": [
"titus",
"episode 5",
"titus 1:10",
"danger",
"empty",
"words",
"false",
"teacher",
"titus 1:13",
"truth",
"before",
"father",
"episode",
"1:10",
"13a",
"opening",
"study",
"give",
"word",
"name",
"hearts",
"minds",
"against",
"love",
"more"
],
"createdAt": "2026-04-13T13:11:07.396Z",
"updatedAt": "2026-04-14T17:22:04.903Z"
},
{
"id": "dd6b3af4-44a5-43f1-9d69-ab12c63dd46d",
"type": "episode",
"title": "Episode 6 — Words That Deny What We Claim to Believe (Titus 1:13b–16)",
"content": "EPISODE 6 — Words That Deny What We Claim to Believe Titus 1:13b–16 · 10–12 minutes ✝ OPENING PRAYER [Pause before opening the text — invite God into the study] Father, give us honest hearts today. You know us fully — every word we speak and every choice we make. Nothing is hidden from you. And so we come to you asking for something only you can give: the grace to live with integrity. Where our lives don't match what we say we believe — convict us. Not to shame us, but to restore us. To bring us back to integrity. As we open up Your Word today please convict us and draw us back to You - so what we say and how we live are in alignment with you. In Jesus’ name I pray, Amen. 🎙️ COLD OPEN [Personal and a little uncomfortable — invite honest self-examination from the start] It is possible to profess faith with your mouth and deny it with your life. That sentence is not pleasant to sit with. But Paul puts it plainly in verse 16, and he doesn’t soften it. He is describing people who say the right things and live the wrong ones — whose theology is correct on paper but nowhere in evidence in how they actually behave. And here’s the uncomfortable part: Paul is not writing this only about the false teachers in Crete. He is writing it as a mirror. Today on Episode 6, we finish chapter 1. We’re in Titus 1, verses 13b through 16. 📖 SEGMENT 1 — A DEFILED CONSCIENCE AND A DISTORTED WORLD [This is a diagnostic segment — help listeners understand the mechanism] Verse 15 contains one of the most psychologically precise statements in all of Paul’s letters: To the pure, all things are pure; but to the defiled and unbelieving, nothing is pure. Indeed, both their minds and their consciences are defiled. A person with a clean conscience — someone whose heart has been washed by the gospel — can engage with the world from a place of trust and clarity. But a person whose conscience is defiled — corrupted by sin, self-deception, or bad doctrine — can’t see anything clearly. Everything gets filtered through that corruption. Even what is pure looks suspect to them. This is why false teaching is so damaging at the root level. It doesn’t just change what people believe — it changes how they see. Think about what that looks like practically. A person shaped by bad doctrine doesn’t just hold wrong opinions — they become suspicious of grace, hostile toward correction, and unable to receive what is genuinely good. They see generosity and call it ingenuousness. They see freedom and call it license. The very gifts of the gospel become things they can’t trust, because the lens they’re looking through has been cracked. That’s the devastation Paul is describing — not just theological error, but a person who has lost the ability to see clearly. 📖 SEGMENT 2 — JEWISH MYTHS AND COMMANDS OF MEN [Connect back to Episode 5 — build the thread] Verse 14 tells us specifically what these false teachers were promoting — Jewish myths and the commands of people who have rejected the truth. Jewish myths likely refers to speculative expansions of Old Testament stories. Commands of men refers to human-made religious rules elevated to the level of divine requirement. And here’s why Paul sees this as a gospel issue: when you add human requirements to the gospel, you shift the basis of acceptance before God from what Christ has done to what you do. The result is a Christianity more anxious, more performance-driven, more focused on the maintenance of rules than on the freedom of grace. We should be honest that this isn’t only a first-century problem. The “commands of men” show up in modern dress all the time — in churches that quietly teach that real Christians hold certain political views, in communities where your worth is measured by how often you volunteer, how much you give, or whether you use the right vocabulary. None of those things are bad in themselves. But the moment they become the measure of your standing before God, the gospel has been displaced. Paul’s warning is just as sharp now as it was in Crete. 📖 SEGMENT 3 — DETESTABLE, DISOBEDIENT, UNFIT [This is the hard section — don’t apologize for it, but explain it with pastoral care] Verse 16 ends with a devastating summary: They profess to know God, but by their actions they deny Him. They are detestable, disobedient, and unfit for any good deed. Three words. Detestable. Disobedient. Unfit for any good deed. The first word Paul uses - Detestable - describes something morally repulsive — something unacceptable before God. It’s the same kind of word the Old Testament used for things that were considered abominations. In other words, these teachers may look religious on the outside, but in God’s sight their teaching and conduct are deeply offensive. The second word - Disobedient - describes someone who is not persuadable — someone resistant to truth. Not merely confused, but unwilling to sLine truncated
"keywords": [
"titus",
"episode 6",
"titus 1:13",
"deny",
"claim",
"believe",
"titus 1:16",
"episode",
"today",
"live",
"say",
"paul",
"words",
"1:13b",
"opening",
"invite",
"honest",
"every",
"word",
"only",
"integrity",
"convict",
"back"
],
"createdAt": "2026-04-13T13:11:07.417Z",
"updatedAt": "2026-04-14T17:22:04.903Z"
},
{
"id": "1559c4af-3913-4a0d-9ac3-a59172adabc5",
"type": "episode",
"title": "Episode 7 — Sound Doctrine for Every Stage of Life (Titus 2:1–10)",
"content": "EPISODE 7 — Sound Doctrine for Every Stage of Life Titus 2:1–10 · 12–14 minutes ✝ OPENING PRAYER [Pause before opening the text — invite God into the study] Lord, remind us that the gospel is not just for Sunday — it’s for every stage, every season, every relationship. Speak through this passage to wherever we are in life. In Jesus’ name, Amen. 🎙️ COLD OPEN [Sharp pivot — let the contrast with chapter 1 land immediately] Chapter 2 opens with two words that change everything: “But you.” Paul has just finished a difficult chapter — false teachers, empty talkers, defiled consciences, people whose lives deny what their lips confess. And now he pivots — sharply, deliberately — and turns to Titus directly: But as for you, speak the things that are consistent with sound doctrine. The contrast is intentional. The false teachers spoke empty words. You speak sound doctrine. And what sound doctrine actually says — Paul is about to show us — has something to say to every person in every stage of life. 📖 SEGMENT 1 — THE PIVOT FROM WARNING TO VISION [Brief orientation — help listeners feel the structure shift] Chapter 1 was about threats — the things that were wrong, the people causing damage, the patterns that needed to be stopped. It was a necessary chapter. You can’t build well if you haven’t cleared the ground. Chapter 2 is about vision. Not what are we against, but what are we for. Not just what does sound doctrine forbid, but what does it produce? And what it produces is a community that looks different. Every generation investing in the next. Older believers modeling what long faithfulness looks like. Younger believers learning to bring their appetites and ambitions under the direction of the gospel. Workers whose ordinary faithfulness makes the gospel attractive. This is not a vision of individual spiritual achievement. It is a vision of intergenerational community shaped by grace. 📖 SEGMENT 2 — OLDER MEN [Dignified and warm — honor this group without being stiff] Verse 2 describes what Paul wants Titus to cultivate in older men: Older men are to be temperate, dignified, self-controlled, and sound in faith, love, and perseverance. Four qualities of character, and three qualities of soul. Temperate. Dignified. Self-controlled. These describe a man who has learned — through years of walking with God and being humbled by life — to govern himself. And then: sound in faith, love, and perseverance. Faith that hasn’t grown cynical. Love that hasn’t grown cold. Perseverance that hasn’t given up. The older man who embodies these qualities is not just personally virtuous — he is a witness. He shows the younger generation that it is possible to follow Jesus for decades and still be full of faith, still warm in love, still standing. That’s a gift no sermon can replace. 📖 SEGMENT 3 — OLDER WOMEN AND YOUNGER WOMEN [Handle with care and warmth — this is a passage about discipleship, not restriction] Verses 3 through 5 describe older women and their specific role in the life of younger women: Older women, likewise, are to be reverent in their behavior, not slanderers or addicted to much wine, but teachers of good. In this way they can train the young women to love their husbands and children, to be self-controlled, pure, managers of their households, kind, and submissive to their own husbands, so that the word of God will not be discredited. The older women Paul describes are not passive. They are reverent — a word used elsewhere for temple service, suggesting a life of sacred intentionality. They are not slanderers. They are not addicted to much wine. They are self-governing. And their primary ministry is to teach younger women. The Greek word for train here — sophronizō — is about forming wisdom and good judgment in someone else. This is not advice-giving. This is mentorship. Investment. One life poured into another. And the reason Paul gives for all of it is stunning: so that the word of God will not be discredited. The way the women in this congregation live their ordinary lives either adorns the gospel or brings it into disrepute. Their faithfulness is a form of witness. 📖 SEGMENT 4 — YOUNGER MEN AND TITUS HIMSELF [Brief and pointed — the simplicity here is the message] The section on younger men is the shortest in the passage. Verse 6: “In the same way, urge the younger men to be self-controlled.” That’s it. One charge. And then Paul immediately turns to Titus in verse 7: In everything, show yourself to be an example by doing good works. In your teaching show integrity, dignity, The connection is important. The best thing Titus can do for the younger men is not give them a list. It’s show them a life. Paul uses the word typos — a pattern, a mold. Titus himself is to be the shape that younger men are formed around. Self-control is not a naturally occurring quality in young men. It has to be learned. And itLine truncated
"keywords": [
"titus",
"episode 7",
"titus 2:1–10",
"titus 2:1",
"sound",
"doctrine",
"every",
"stage",
"life",
"chapter",
"speak",
"episode",
"2:1",
"opening",
"pivot",
"contrast",
"words",
"paul",
"false",
"teachers",
"empty",
"people",
"things"
],
"createdAt": "2026-04-13T13:11:07.441Z",
"updatedAt": "2026-04-14T17:22:04.903Z"
},
{
"id": "6be7a644-9276-4797-a7bb-1422b170846c",
"type": "episode",
"title": "Episode 8 — Grace Is Not Just What Saves You — It’s What Trains You (Titus 2:11–12)",
"content": "EPISODE 8 — Grace Is Not Just What Saves You — It’s What Trains You Titus 2:11–12 · 12–14 minutes ✝ OPENING PRAYER [Pause before opening the text — invite God into the study] Father, thank You that Your grace does not leave us where it finds us. Teach us today — through Your Word — to say no to what destroys us and yes to the life You’ve called us to. In Jesus’ name, Amen. 🎙️ COLD OPEN [Conversational and a little surprising — reframe grace from the first sentence] Most of us understand grace as the thing that got us saved. We needed forgiveness. We couldn’t earn it. God gave it freely through the death and resurrection of Jesus. That’s right. That’s true. And it’s not the whole picture. Because in Titus 2:11 and 12, Paul says something most people have never sat with long enough. He says that grace appeared. He says it brought salvation. And then — in the very next breath — he says it instructs us. Grace instructs. Grace teaches. Grace is not just the event that got you through the door — it is the ongoing teacher that is shaping you inside the house. Today on Episode 8, we’re going to camp out in just two verses. Two verses that may change the way you think about grace entirely. 📖 SEGMENT 1 — GRACE AS TEACHER: THE GRAMMAR MATTERS [Word study — slow and precise, but keep it accessible] Let’s read verses 11 and 12 carefully: For the grace of God has appeared, bringing salvation to everyone. It instructs us to renounce ungodliness and worldly passions, and to live sensible, upright, and godly lives in the present age, Now — look at the grammar of verse 12. The subject of the sentence is ‘it.’ And ‘it’ refers back to grace. Grace instructs us. Grace is the subject. Grace is doing the teaching. The Greek word Paul uses is paideuō — the word for the education of a child. Not classroom instruction. Formation. The kind of learning that shapes not just what you know but who you are. Grace is the tutor. And the curriculum it’s teaching has two parts: what to say no to, and what to say yes to. 📖 SEGMENT 2 — WHAT GRACE TEACHES US TO SAY NO TO [Clear and direct — don’t hedge on what Paul actually says] Grace teaches us to renounce two things: ungodliness and worldly passions. Ungodliness — asebeia — is the opposite of reverence. A life where God is not actually at the center. Where decisions are made without reference to Him. Worldly passions — desires shaped by the age we live in rather than the kingdom we’ve been brought into. And here’s what I want you to notice: Paul doesn’t say that moral effort teaches us to renounce these things. He says grace teaches us. The pathway to saying no to ungodliness is going deeper into grace — understanding more fully what you have been given. Grace, properly understood, reorients your desires. It doesn’t just change the rules. It changes what you want. 📖 SEGMENT 3 — WHAT GRACE TEACHES US TO SAY YES TO [Three words — give each one its own moment] On the other side, grace teaches three orientations: sensible, upright, and godly. Sensible — sōphrōn — a sound, well-ordered mind. Not repressed. Not uptight. But governed. A person whose interior life is ordered enough that they can engage with the world without being swept away by it. Upright — dikaios — right in relationship with others. Fair. Honest. The gospel changes how we treat people because it has changed how we understand our own standing before God. Godly — eusebōs — living with genuine reverence. A life where God is actually at the center, not just in stated belief, but in the practical orientation of each day. Inward (sensible), outward (upright), and upward (godly). Grace reshapes the whole person. 📖 SEGMENT 4 — THE KEY WORD: CHARIS [Brief but important — this is the word the whole letter is built on] The Greek word for grace is charis — χάρις. It appears in the opening greeting (1:4) and in the closing benediction (3:15), bookending the entire letter. Charis means unmerited favor. A gift freely given with no precondition of worthiness. But Paul uses it here as a teacher — it initiates AND sustains. The grace that started the work in you is the same grace that is continuing it. This is why trying harder without grace fails. Moral effort without a gospel foundation is white-knuckle religion. And this is also why grace without obedience is incomplete. Grace is so powerful that it actually produces change. If it’s not producing anything in your life, Paul would want to ask whether you’ve actually encountered it. 💬 DISCUSSION QUESTIONS [Personal and searching — these questions go deep] Three questions to carry this week: 1. In what ways is grace a teacher? How does understanding what God has freely given change your motivation for how you live? 2. What’s the difference between obeying out of fear or duty versus obeying as a response to grace? What does that difference fLine truncated
"keywords": [
"titus",
"episode 8",
"titus 2:11–12",
"titus 2:11",
"grace",
"just",
"what",
"saves",
"trains",
"says",
"episode",
"2:11",
"god",
"instructs",
"verses",
"opening",
"study",
"today",
"word",
"jesus",
"most",
"got",
"appeared",
"salvation",
"two"
],
"createdAt": "2026-04-13T13:11:07.461Z",
"updatedAt": "2026-04-14T17:22:04.903Z"
},
{
"id": "4e6ec41f-b083-45b9-9581-0dc91f092f66",
"type": "episode",
"title": "Episode 9 — Living Between Two Appearings (Titus 2:13–15)",
"content": "EPISODE 9 — Living Between Two Appearings Titus 2:13–15 · 10–12 minutes ✝ OPENING PRAYER [Pause before opening the text — invite God into the study] Lord Jesus, You came once to save us. You are coming again to complete what You started. In the in-between — right now — give us eyes to live with that hope shaping every choice. In Your name, Amen. 🎙️ COLD OPEN [A sense of scale — help listeners feel the sweep of the three timeframes] One of the things Paul does throughout Titus is give believers a three-tense framework for their lives. Past: what Christ has already done. His first appearing — the incarnation, the cross, the resurrection. Grace arrived. The work is finished. Present: what we do now. The in-between age — where we live out the implications of that past event while we wait for what’s coming. Future: what we’re waiting for. The second appearing. The return. The completion of everything begun at the first. Today on Episode 9, we’re going to look at how Paul holds all three together in just three verses — and why the blessed hope changes everything about the present. 📖 SEGMENT 1 — THE THREE TIMEFRAMES [Brief and structural — help listeners see the architecture] Verse 11: “For the grace of God has appeared.” Past tense. The first appearing. The foundation. Verse 12: “…to live sensible, upright, and godly lives in the present age.” Present tense. The current moment. Verse 13: …as we await the blessed hope and glorious appearance of our great God and Savior Jesus Christ. Future tense. We are waiting actively, with anticipation, with our behavior shaped by what we know is coming. Past event. Present calling. Future hope. You can’t live the present well without both the foundation behind you and the horizon ahead of you. 📖 SEGMENT 2 — THE BLESSED HOPE [Let the phrase breathe — this is an anchor, not a platitude] Verse 13 uses a phrase that has become so familiar it has almost lost its force: the blessed hope. The Greek is makaria elpis. Makaria is the same word used in the Beatitudes — blessed, in a divinely favored condition. And elpis — hope — in the New Testament is not the way we use the word in everyday conversation. We say ‘I hope it doesn’t rain’ — meaning we’re uncertain, we’re wishing. Biblical hope is a confident expectation of something certain. Not a wish. An anchor. The blessed hope is: Jesus is coming back. Not maybe. Not eventually, probably. He is coming back. And the person who really believes that is in a blessed condition even now, living toward a certainty that changes how everything feels today. 📖 SEGMENT 3 — THE GOSPEL IN ONE SENTENCE [Slow down — verse 14 is one of the most compact gospel statements in the New Testament] Verse 14 does something remarkable. In a single sentence, Paul gives us the full scope of what Christ’s death accomplished: He gave Himself for us to redeem us from all lawlessness and to purify for Himself a people for His own possession, zealous for good deeds. Two verbs. Redeem and purify. Redeem — lytroō — a word from the slave market and from Exodus. To pay a price to set someone free. What were we freed from? All lawlessness. Purify — katharizō — from the temple. To cleanse, to make acceptable. We weren’t just released from prison — we were made clean. Belonging. And look at the end: ‘a people for His own possession, zealous for good deeds.’ The word zelotes — eager, passionate, on fire. Not reluctant. Not dragging their feet. People who are genuinely excited about doing good because they understand what they’ve been rescued for. 📖 SEGMENT 4 — SPEAK WITH AUTHORITY [Brief and direct — this is Paul’s charge to Titus, and to every teacher] Paul closes the chapter with a charge to Titus: Speak these things as you encourage and rebuke with all authority. Let no one despise you. Speak these things. All of it — the grace, the training, the blessed hope, the gospel in one sentence. Don’t soften it. Don’t apologize for it. With all authority. Not Titus’s own authority — the authority of the message itself. The gospel doesn’t need Titus’s permission to be true. His job is to represent it faithfully and let it carry its own weight. 💬 DISCUSSION QUESTIONS [These open up the future dimension of faith practically] Three questions for this week: 1. How does actively anticipating the return of Christ affect the way you make decisions and treat people today? Does the blessed hope feel like a real anchor in your life? 2. What does it mean to you personally that Christ ‘gave Himself’ — that it was intentional, not accidental? 3. Do you experience your Christian life as something you belong to — a people claimed by God — or mostly as something you have to maintain on your own? ✦ CLOSING [Grounded and direct — send them away with something to carry] We are living between two great appearings of grace. The first estLine truncated
"keywords": [
"titus",
"episode 9",
"titus 2:13–15",
"titus 2:13",
"living",
"between",
"appearings",
"three",
"episode",
"god",
"live",
"hope",
"first",
"appearing",
"present",
"verse",
"tense",
"two",
"2:13",
"opening",
"jesus",
"coming",
"in-between"
],
"createdAt": "2026-04-13T13:11:07.484Z",
"updatedAt": "2026-04-14T17:22:04.903Z"
},
{
"id": "41233c63-d2b4-4fd3-9054-1afb11c37287",
"type": "episode",
"title": "Episode 10 — How the Gospel Forms a Community (Titus 3:1–2)",
"content": "EPISODE 10 — How the Gospel Forms a Community Titus 3:1–2 · 10–11 minutes ✝ OPENING PRAYER [Pause before opening the text — invite God into the study] Father, give us the grace to be peaceable in a contentious world. Where we are tempted to fight, teach us to witness. Where we are tempted to withdraw, push us into purposeful engagement. In Jesus’ name, Amen. 🎙️ COLD OPEN [Grounded and culturally aware — this passage has immediate relevance] The gospel doesn’t just change individuals. It changes how we relate to the society around us. Chapter 3 of Titus opens with a set of instructions that are startlingly direct for our current moment. Be subject to rulers and authorities. Be obedient. Be ready for every good work. Malign no one. Be peaceable. Be gentle. Show full consideration to everyone. In an age of political polarization, social media outrage, and culture-war Christianity, these two verses feel almost countercultural. They don’t describe a community of culture warriors. They describe a community of witnesses. 📖 SEGMENT 1 — WHY PAUL OPENS CHAPTER 3 WITH CIVIC SUBMISSION [Missional logic — help listeners understand the why before the what] Paul is not saying: agree with every government decision. He is saying: your engagement with public life should be marked by something recognizable as different. Your submission, your readiness to do good, your peaceable presence — these are not just civic virtues. They are missionary strategies. The way the community in Crete navigates public life is either going to open doors for the gospel or close them. 📖 SEGMENT 2 — THREE ACTIVE ORIENTATIONS [Take each one briefly but clearly] Verse 1: Remind the believers to submit to rulers and authorities, to be obedient and ready for every good work, Submit to rulers and authorities — a posture of respect toward legitimate governing structures. The believer’s default is cooperation rather than defiance. Be obedient — follow through. Actually do what is asked. People whose lives don’t create unnecessary friction with the society around them. Be ready for every good work — active, not passive. Readiness implies preparation and eagerness. The believer’s posture is not just ‘don’t cause problems.’ It’s ‘be looking for opportunities to do good.’ 📖 SEGMENT 3 — THE POSTURE OF BELIEVERS IN A CONFLICT-SATURATED CULTURE [This is the most culturally pointed section — speak to it directly] Verse 2 adds four more qualities: …to malign no one, and to be peaceable and gentle, showing full consideration to everyone. Malign no one. In a culture built on callouts, political attack cycles, and the steady background noise of contempt — this is genuinely countercultural. Paul doesn’t say: malign no one except your political opponents. He says no one. Peaceable — amachous — literally non-combative. Alpha (not) + machē (battle). People who are not itching for a fight, not framing every interaction as a conflict to be won. Gentle — epieikēs — gracious, not insisting on the full weight of one’s rights. Showing full consideration to everyone — not just the likable, the agreeable, the theologically correct. For all people. This is not weakness. This is the confidence of people who know how the story ends and don’t need to win every argument to prove it. 💬 DISCUSSION QUESTIONS Three questions for this week: 1. How should the gospel shape the way Christians engage with politics, authority, and public life? Where do you see the church getting this right — and where do you see it getting it wrong? 2. What does it look like to be gentle and considerate to everyone in the age of social media? What would need to change in your online presence to reflect the posture Paul describes here? 3. Where do you find it hardest to reflect the peaceable posture Paul describes? What situation or kind of person most tests your gentleness? ✦ CLOSING [Grounded and direct — send them away with something to carry] The gospel sends us into the world not as culture warriors, but as witnesses. The posture Paul calls for — gentle, peaceable, considerate, ready for every good work — is the confidence of people who know who they belong to and what the future holds. The confidence of people who know the King doesn’t look like aggression. It looks like peace. Next episode, before Paul gives us the gospel again in its fullest form, he gives us the diagnosis. Verse 3 is one of the most honest passages in Titus — and Paul includes himself in it. I’m Nate, and this is Verse by Verse with Nate: A Journey Through Scripture. Until next time — keep going. — End of Episode 10 —",
"keywords": [
"titus",
"episode 10",
"titus 3:1–2",
"titus 3:1",
"gospel",
"forms",
"community",
"peaceable",
"episode",
"3:1",
"opening",
"before",
"tempted",
"engagement",
"open",
"chapter",
"opens",
"every",
"good",
"these",
"describe",
"segment",
"paul"
],
"createdAt": "2026-04-13T13:11:07.506Z",
"updatedAt": "2026-04-14T17:22:04.903Z"
},
{
"id": "6d0def21-46cb-497e-ac20-8e09c5368a08",
"type": "episode",
"title": "Episode 11 — Remember What You Were (Titus 3:3)",
"content": "EPISODE 11 — Remember What You Were Titus 3:3 · 10–12 minutes ✝ OPENING PRAYER [Pause before opening the text — invite God into the study] Father, keep us from forgetting. Remind us of what we were — not to shame us, but to make us humble and grateful and gentle toward others who are still in the dark. In Jesus’ name, Amen. 🎙️ COLD OPEN [Personal and unflinching — Paul is in the mirror too] Before Paul gives us the gospel — and in just a few verses he is going to give us one of the most concentrated gospel statements in the entire New Testament — he gives us the diagnosis. He makes us look at the before picture. And what’s remarkable about the way he does it is the word he uses. He doesn’t say ‘you were like this.’ He says ‘we.’ We were foolish. We were enslaved. We were hated and hating one another. Paul places himself in the before picture. The apostle who wrote a third of the New Testament says: I was in there too. Don’t forget that. 📖 SEGMENT 1 — THE ‘WE TOO WERE’ [This is the key move of the passage — give it the weight it deserves] Let’s read the verse: For at one time we too were foolish, disobedient, misled, and enslaved to all sorts of desires and pleasures—living in malice and envy, being hated and hating one another. We ourselves. This is not rhetoric. This is autobiography. Paul is giving instructions on how to correct others — how to rebuke false teachers, how to call the congregation to a higher standard. And right here he stops and says: before you do any of that, remember what you were. This is the ground of Christian humility. Every time a believer looks at someone who is not yet a Christian — someone whose life is being destroyed by these very patterns — the right response is not contempt. It is recognition. I was there. I was enslaved to those same kinds of things. I didn’t save myself. I was saved. 📖 SEGMENT 2 — SEVEN DESCRIPTORS [Take each one carefully — these are specific, not generic] Paul gives us seven words for what we were before grace. Each one is precise. Foolish — anoētos — without understanding. Not unintelligent. Without the wisdom that comes from knowing God. Disobedient — apeitheis — not persuadable, resistant to truth. Misled — planōmenoi — wandering, deceived, off course. Confidently heading in the wrong direction. Enslaved to all sorts of desires and pleasures — douleuontes — serving as slaves. Not choosing. Owned by desires. Living in malice and envy — ordinary corrosive stuff — wanting what others have, being glad when they fail. Being hated and hating one another — the natural end state of a life shaped by malice and envy. Seven words for what we were. And this is not a description of the worst people in the world. This is a description of all of us, before grace. 📖 SEGMENT 3 — HONEST MEMORY AS A PASTORAL GIFT [This is where the practical application opens up] Why does Paul make Titus — and us — look at this? Because the people Titus is ministering to include people who are still in this condition. And it would be easy — especially for a young pastor who has been well-formed by the gospel — to look at them with spiritual impatience. To treat their condition as a problem to be solved rather than a darkness to be illuminated with compassion. Paul is cutting that off at the root. You were there. You did not extricate yourself. You were found. There is a kind of humility that only grows from honest memory. The person who genuinely remembers what they were before grace is not capable of looking down on someone who doesn’t yet have it. They are too aware of the distance grace had to travel to reach them. 💬 DISCUSSION QUESTIONS Three questions to sit with this week: 1. Why is Paul’s autobiographical ‘we’ so important here? How does remembering your own ‘before’ picture shape your attitude toward people who don’t yet know the gospel? 2. Which of the seven descriptors — foolish, disobedient, misled, enslaved, malicious, envious, hated and hating — do you most need to keep in view to stay humble? 3. How does honest self-awareness about what we were become a gift for the way we love others? Where have you seen that in practice? ✦ CLOSING [Grounded and direct — send them away with something to carry] Humility toward others grows from memory. We don’t look down on where people are — because we remember where we were. And we remember that we didn’t save ourselves. Grace came to us. Grace found us in the dark and brought us out. That memory is not a wound. It’s a gift. Next episode, we get the gospel in its fullest form — Titus 3:4 through 7 is one of the most concentrated and beautiful gospel summaries anywhere in the New Testament. I’m Nate, and this is Verse by Verse with Nate: A Journey Through Scripture. Until next time — keep going. — End of Episode 11 —",
"keywords": [
"titus",
"episode 11",
"titus 3:3",
"titus 3:4",
"remember",
"what",
"were",
"before",
"paul",
"too",
"one",
"episode",
"3:3",
"opening",
"others",
"who",
"gives",
"gospel",
"give",
"new",
"testament",
"picture",
"foolish",
"enslaved",
"hated"
],
"createdAt": "2026-04-13T13:11:07.532Z",
"updatedAt": "2026-04-14T17:22:04.903Z"
},
{
"id": "b68c5659-cc7e-42d2-ab24-a623b7404058",
"type": "episode",
"title": "Episode 12 — The Gospel in One Paragraph (Titus 3:4–7)",
"content": "EPISODE 12 — The Gospel in One Paragraph Titus 3:4–7 · 12–15 minutes ✝ OPENING PRAYER [Pause before opening the text — invite God into the study] Father, Son, and Holy Spirit — three persons, one gospel. Thank You that our salvation rests not on what we have done, but on Your kindness, Your mercy, and Your love. Let that truth be more than doctrine today — let it be life. Amen. 🎙️ COLD OPEN [Reverent and unhurried — treat this passage with the weight it deserves] There are passages in the New Testament that you have to slow down for. Not because they are difficult. But because they are so full that if you move through them at normal speed, you come out the other side not quite sure what just happened. Titus 3:4 through 7 is one of those passages. In four verses, Paul gives us one of the most complete summaries of the gospel anywhere in the New Testament. Every component of salvation is present. The character of God. The basis of rescue. The work of the Holy Spirit. Justification. Adoption. Eternal inheritance. Paul wants Titus to know exactly what he is proclaiming. Not a self-improvement program. Not a moral upgrade. A rescue. But when the kindness of God our Savior and His love for mankind appeared, He saved us, not by the righteous deeds we had done, but according to His mercy, through the washing of new birth and renewal by the Holy Spirit. This is the Spirit He poured out on us abundantly through Jesus Christ our Savior, so that, having been justified by His grace, we would become heirs with the hope of eternal life. 📖 SEGMENT 1 — SALVATION IS TRINITARIAN [This is theologically rich — make it feel personal, not academic] All three persons of the Trinity are present and active in our salvation. This is not accidental — Paul is showing us that the gospel is not a one-person operation. God the Father — verse 4 speaks of ‘the kindness of God our Savior and His love for mankind.’ The initiating motivation for salvation is the character of the Father. He is kind. He is full of love toward humanity. Our rescue begins in the heart of God. The Holy Spirit — verse 5 speaks of ‘the washing of new birth and renewal by the Holy Spirit.’ The Spirit is the agent of the new birth — the one who applies the work of Christ to the individual soul. Jesus Christ — verse 6 names Him explicitly as the one through whom the Spirit is poured out, and verse 7 calls Him ‘our Savior.’ Father, Son, Spirit — each person fully present, each playing a distinct role, all working together in the single act of saving a person. This is not just theology. It is the shape of your own story. 📖 SEGMENT 2 — NOT BY OUR DEEDS: MERCY, NOT MERIT [This is the foundation — clear and unequivocal] Verse 5 contains one of the most explicit denials of works-based salvation in the New Testament: But when the kindness of God our Savior and His love for mankind appeared, He saved us, not by the righteous deeds we had done, but according to His mercy, through the washing of new birth and renewal by the Holy Spirit. Not by the righteous deeds we had done. Not partially. Not mostly. The basis of salvation is not our righteousness. It is His mercy. The theological term for this is monergism — salvation as a single-agent work. God does the saving. We receive it. There was nothing in the raw material that deserved rescue. The rescue happened anyway, because of mercy. And why does this matter so much? Because if salvation is even one percent based on our merit, it is unstable. But if salvation is entirely based on God’s mercy — on the character of a God who cannot lie and who loved humanity enough to appear as a human and die — then it rests on something that cannot change. 📖 SEGMENT 3 — NEW BIRTH AND RENEWAL [Two Greek words — explain carefully but keep it accessible] Verse 5 gives us two words for what the Spirit does in us: the washing of new birth and renewal. Washing of new birth — palingenesia — literally ‘again-genesis.’ A new beginning. A second creation. This is the moment the Spirit brings a spiritually dead person to life. A definitive event. A before-and-after. Renewal by the Holy Spirit — anakainōsis — ongoing work. Continuous present tense in the Greek. The Spirit who brought you to life at regeneration is also continually renewing you. Both are needed. New birth without renewal produces a static faith. Renewal without new birth produces endless effort without a genuine starting point. Both are the Spirit’s work. Your part is to cooperate with what He is already doing. 📖 SEGMENT 4 — JUSTIFIED AND HEIRS [Two more key words — forensic and relational, both important] Verse 7 adds two more categories: This is the Spirit He poured out on us abundantly through Jesus Christ our Savior, so that, having been justified by His grace, we would become heirs with the hope of eternal life. Justified — dikaiōthentes — a courtroom term. To be declareLine truncated
"keywords": [
"titus",
"episode 12",
"titus 3:4–7",
"titus 3:4",
"gospel",
"paragraph",
"one",
"spirit",
"3:4",
"god",
"holy",
"salvation",
"new",
"episode",
"opening",
"done",
"kindness",
"mercy",
"love",
"let",
"life",
"passages",
"testament"
],
"createdAt": "2026-04-13T13:11:07.554Z",
"updatedAt": "2026-04-14T17:22:04.903Z"
},
{
"id": "c8db1a86-346f-4bad-ba4f-30c29d95feb3",
"type": "episode",
"title": "Episode 13 — What Happens After the Gospel Takes Root (Titus 3:8–11)",
"content": "EPISODE 13 — What Happens After the Gospel Takes Root Titus 3:8–11 · 10–12 minutes ✝ OPENING PRAYER [Pause before opening the text — invite God into the study] Lord, let the gospel in us produce fruit — real, visible, good-work fruit. Guard us from the pointless arguments that divide, and keep us devoted to the things that build. In Jesus’ name, Amen. 🎙️ COLD OPEN [Grounded and practical — this passage is a test of whether the gospel is working] Paul has just given us the most beautiful gospel summary in the letter. Father, Son, Spirit. Mercy, not merit. Justified. Heirs. Eternal life promised before the ages began. And now he asks the practical question every good theologian eventually has to ask: what does a community look like when these truths actually land? Because the gospel is not a conversation starter. It is a life transformer. And Paul has a very simple test for whether theology is working: Are people becoming more devoted to good works? Or more devoted to winning arguments? 📖 SEGMENT 1 — THE FAITHFUL SAYING [Brief orientation — the formula matters] Verse 8: This saying is trustworthy. And I want you to emphasize these things, so that those who have believed God will take care to devote themselves to good deeds. These things are excellent and profitable for the people. Pistos ho logos — this saying is trustworthy. Paul uses this formula in his letters to Timothy and Titus to mark something as having the highest doctrinal weight. What he just said in verses 4 through 7 is the trustworthy, reliable, foundational word. Bank on it. Build on it. And then immediately: the expected outcome. Those who have believed God will take care to devote themselves to good deeds. The test of the gospel having taken root is not doctrinal sophistication. It is practical devotion to good works. 📖 SEGMENT 2 — PROFITABLE VS. POINTLESS AND WORTHLESS [Paul’s contrast is stark — let it land] Verse 9: But avoid foolish controversies, genealogies, arguments, and quarrels about the law, because these things are pointless and worthless. Profitable — ōphélima. Pointless and worthless — anōphelēs and mataioi. The contrast is total. Good works produce something. They are excellent — kalá — beautiful, genuinely valuable. They benefit people. They demonstrate the gospel in tangible ways. Foolish controversies, on the other hand, produce nothing. They generate heat but no light. They create factions but no fruit. The test Paul applies is not: is this interesting? Is this theologically sophisticated? The test is: does engaging with this produce anything? Does it make people more devoted to good works? 📖 SEGMENT 3 — SOUND DOCTRINE PRODUCES GOOD WORKS [The through-line of the whole letter — connect everything] Let me name the thread Paul has been pulling through this entire letter, because we’re near the end and it is clearer here than anywhere else. From chapter 1, verse 1: truth that leads to godliness. Elders who hold firmly to sound doctrine. False teachers whose lives deny what they claim to believe. Chapter 2: grace that instructs us to live sensible, upright, godly lives. Ordinary faithfulness that adorns the gospel. Chapter 3: people who have believed God devoting themselves to good deeds. The thread is this: sound doctrine and transformed living are not separate categories. Doctrine is the seed. Good works are the fruit. You cannot have genuine doctrine without eventual fruit, and you cannot sustain genuine good works without doctrinal roots. 📖 SEGMENT 4 — THE DIVISIVE PERSON: TWO WARNINGS, THEN REJECTION [Careful here — explain the process and the heart behind it] Verses 10 and 11: Reject a divisive man after a first and second admonition, knowing that such a man is corrupt and sinful; he is self-condemned. Two warnings. Not immediate removal. Not endless patience. Two warnings. One warning is not enough — it doesn’t give the person a genuine chance to respond. The second warning is confirmation that the first was heard and not responded to. After two warnings with no change — have nothing more to do with him. And Paul’s assessment: self-condemned. The verdict is not coming from the outside. The person who persists in division after two clear warnings has essentially condemned themselves by their own choices. The community is simply recognizing what is already true. This is not cruelty. It is honesty. And it protects the people who are being divided. 💬 DISCUSSION QUESTIONS Three questions for this week: 1. Why is good theology supposed to produce good deeds — and what happens in a person or a church when doctrine and life become disconnected from each other? 2. What kinds of theological discussions do you think fall into Paul’s ‘pointless and worthless’ category today? How do you tell the difference between a discussion worth having and one that just generates heat? 3. Why does Paul prescribe two warnings rather than immediLine truncated
"keywords": [
"titus",
"episode 13",
"titus 3:8–11",
"titus 3:8",
"what",
"happens",
"after",
"gospel",
"takes",
"root",
"devoted",
"things",
"paul",
"good",
"these",
"saying",
"trustworthy",
"episode",
"3:8",
"opening",
"before",
"god",
"fruit",
"arguments"
],
"createdAt": "2026-04-13T13:11:07.582Z",
"updatedAt": "2026-04-14T17:22:04.903Z"
},
{
"id": "14ced988-1382-4c8d-a29e-b0f531580af1",
"type": "episode",
"title": "Episode 14 — Grace: Where It Starts and Where It Ends (Titus 3:12–15)",
"content": "EPISODE 14 — Grace: Where It Starts and Where It Ends Titus 3:12–15 & Full Letter Review · 13–15 minutes ✝ OPENING PRAYER [Pause before opening the text — invite God into the study] Father, as we close this study, let grace be more than a word we’ve studied. Let it be the frame of our lives — where we start every day and where we end it. Thank You for the journey through Titus. May what we’ve learned take root and bear fruit. In Jesus’ name, Amen. 🎙️ COLD OPEN [Warm and reflective — this is the final episode; honor the journey] Paul closes his letter the way he opened it. The opening greeting of Titus is: To Titus, my true child in our common faith: Grace and peace from God the Father and Christ Jesus our Savior. The closing benediction of Titus is: Grace be with all of you. The first word of the letter’s theological content is grace. The last word is grace. Everything in between — the elders, the false teachers, the household instructions, the gospel paragraph, the good works, the divisive person — all of it is inside the frame of grace. Today on Episode 14, our final episode, we’re going to look at the closing verses of Titus, take one more look at the whole letter, and ask: what are we supposed to carry out of this study? 📖 SEGMENT 1 — REAL PEOPLE IN A REAL COMMUNITY [Personal and grounding — these aren’t props, they’re people] Verses 12 through 14 name four people: Artemas, Tychicus, Zenas the lawyer, and Apollos. Tychicus appears in several of Paul’s letters as a trusted messenger. Apollos is mentioned in Acts and 1 Corinthians as a gifted teacher. Zenas is only mentioned here. Artemas we know nothing about beyond this verse. Why does Paul name them? Because the gospel travels through people. Not as an abstract idea floating through the air, but carried by specific men and women who have been changed by it and who are willing to take it somewhere difficult at personal cost. Titus is not alone on Crete. He is part of a network. The church in the first century was a community of people in motion — bound together by a shared gospel and a shared Lord. That is still how it works. 📖 SEGMENT 2 — DEVOTED TO GOOD WORKS TO MEET PRESSING NEEDS [Practical and concrete — this is the gospel in action] Verse 14: And our people must also learn to devote themselves to good works in order to meet the pressing needs of others, so that they will not be unfruitful. Two things worth noticing. First: let our people learn. Devotion to good works has to be cultivated, practiced, formed over time. It doesn’t just happen because you believe the right things. Second: to meet the pressing needs of others. The word is anagkaias — necessary, pressing, urgent. Paul is not describing optional generosity. He is describing a community that has made itself available for the real needs that arise around it. And the consequence of not doing this: be unfruitful. The gospel that has been received and believed and proclaimed — if it doesn’t produce this kind of practical, neighbor-serving fruit — something is missing. 📖 SEGMENT 3 — GRACE AS BOOKEND [This is the structural and theological capstone — give it room] Let’s look at the two times grace appears at the frame of this letter. Titus 1:4 — the opening: “Grace and peace from God the Father and Christ Jesus our Savior.” In the opening, grace is the origin. It comes from God and from Christ. It is something received, given from outside, descending as pure gift. Titus 3:15 — the closing: “Grace be with all of you.” In the closing, grace is the blessing. It is what Paul wants them to be with — not wealth, not safety, not resolved problems. Grace. Everything that happens between those two bookends flows from the first grace and points toward the second. The elders who lead well are stewards of grace. The community formed generation by generation is shaped by grace. The gospel paragraph is the concentrated statement of grace. The good works that follow are the fruit of grace. Grace is not a footnote in Titus. It is the frame. It is the air the letter breathes. 📖 SEGMENT 4 — THE LETTER’S ONE COHERENT VISION [A careful, appreciative survey — help listeners feel the whole] Let me take you through the structure of the letter one final time. Chapter 1 — Leadership. Establish qualified elders. The elder who holds firmly to sound doctrine is the church’s first line of protection. Character is not the entry point for leadership — it is the definition of it. Chapter 2 — Community Formation. Every generation — older men, older women, younger women, younger men, enslaved people — is addressed. Grace instructs. Grace trains. Grace produces a particular kind of life in every stage and every situation. Chapter 3 — Civic and Social Life. Engage with submission, with gentleness, with readiness to do good. Remember what you were. The same grace that found you is the grace you are carryLine truncated
"keywords": [
"titus",
"episode 14",
"titus 3:12",
"titus 1:4",
"titus 3:15",
"grace",
"starts",
"ends",
"review",
"episode",
"letter",
"opening",
"study",
"word",
"grace:",
"3:12",
"god",
"father",
"let",
"more",
"frame",
"journey",
"take",
"jesus",
"name"
],
"createdAt": "2026-04-13T13:11:07.605Z",
"updatedAt": "2026-04-14T17:22:04.903Z"
}
]
+58
View File
@@ -0,0 +1,58 @@
[
{ "query": "who was titus", "expectedEntryId": "cb_who_is_titus_001", "expectedTopK": 3 },
{ "query": "who is paul", "expectedEntryId": "881279c7-3ca4-436d-a584-19cc0a5a9bf3", "expectedTopK": 3 },
{ "query": "tell me about paul in titus", "expectedEntryId": "881279c7-3ca4-436d-a584-19cc0a5a9bf3", "expectedTopK": 4 },
{ "query": "who was saul before his conversion", "expectedEntryId": "167fa083-5438-44a5-ac72-6dd379e937f5", "expectedTopK": 4 },
{ "query": "who was titus and why was he in crete", "expectedEntryId": "cb_who_is_titus_001", "expectedTopK": 12 },
{ "query": "what bible translation does nate use", "expectedEntryId": "cb_001", "expectedTopK": 1 },
{ "query": "does the podcast use bsb", "expectedEntryId": "cb_001", "expectedTopK": 2 },
{ "query": "which bible version do you teach from", "expectedEntryId": "cb_001", "expectedTopK": 2 },
{ "query": "how can i stay consistent with daily bible reading", "expectedEntryId": "cb_003", "expectedTopK": 2 },
{ "query": "help me build a daily reading habit", "expectedEntryId": "cb_003", "expectedTopK": 3 },
{ "query": "i keep missing devotion time what should i do", "expectedEntryId": "cb_003", "expectedTopK": 4 },
{ "query": "how should christians engage with politics", "expectedEntryId": "cb_christians_politics_001", "expectedTopK": 2 },
{ "query": "what does titus say about public life", "expectedEntryId": "cb_christians_politics_001", "expectedTopK": 3 },
{ "query": "how can i be peaceable online", "expectedEntryId": "cb_christians_politics_001", "expectedTopK": 4 },
{ "query": "give me a summary of titus 3:4-7", "expectedEntryId": "cb_titus347_summary_001", "expectedTopK": 1 },
{ "query": "summarize titus 3 4 through 7", "expectedEntryId": "cb_titus347_summary_001", "expectedTopK": 12 },
{ "query": "what does titus 3:4-7 teach about salvation", "expectedEntryId": "b68c5659-cc7e-42d2-ab24-a623b7404058", "expectedTopK": 2 },
{ "query": "what is the blessed hope", "expectedEntryId": "cb_blessed_hope_001", "expectedTopK": 2 },
{ "query": "define blessed hope from titus 2:13", "expectedEntryId": "cb_blessed_hope_001", "expectedTopK": 2 },
{ "query": "what does makaria elpis mean", "expectedEntryId": "cb_blessed_hope_001", "expectedTopK": 3 },
{ "query": "what does grace train us to do", "expectedEntryId": "cb_grace_trains_001", "expectedTopK": 2 },
{ "query": "how does grace teach us to say no to ungodliness", "expectedEntryId": "cb_grace_trains_001", "expectedTopK": 3 },
{ "query": "what is paideuo in titus 2", "expectedEntryId": "6be7a644-9276-4797-a7bb-1422b170846c", "expectedTopK": 12 },
{ "query": "how do i submit a question to nate", "expectedEntryId": "cb_007", "expectedTopK": 1 },
{ "query": "where can i send bible questions", "expectedEntryId": "cb_007", "expectedTopK": 12 },
{ "query": "how do i contact nate", "expectedEntryId": "cb_007", "expectedTopK": 2 },
{ "query": "what does titus 1 teach about church leadership", "expectedEntryId": "cb_leadership_titus1_001", "expectedTopK": 2 },
{ "query": "elder qualifications in titus 1", "expectedEntryId": "cb_leadership_titus1_001", "expectedTopK": 12 },
{ "query": "what should an overseer be like", "expectedEntryId": "cb_leadership_titus1_001", "expectedTopK": 3 },
{ "query": "what did paul say about elders", "expectedEntryId": "e0ad268f-2826-41a5-b078-90f90aeda21b", "expectedTopK": 4 },
{ "query": "episode on faithful leaders", "expectedEntryId": "e0ad268f-2826-41a5-b078-90f90aeda21b", "expectedTopK": 3 },
{ "query": "titus 1:5-9 overview", "expectedEntryId": "e0ad268f-2826-41a5-b078-90f90aeda21b", "expectedTopK": 12 },
{ "query": "what are false teachers doing in titus", "expectedEntryId": "56626549-7e1e-4564-8c79-2d21b49eb911", "expectedTopK": 3 },
{ "query": "what is the circumcision group in titus", "expectedEntryId": "56626549-7e1e-4564-8c79-2d21b49eb911", "expectedTopK": 3 },
{ "query": "empty talk and deception in titus 1", "expectedEntryId": "56626549-7e1e-4564-8c79-2d21b49eb911", "expectedTopK": 4 },
{ "query": "what does it mean to deny god by your works", "expectedEntryId": "cb_deny_works_001", "expectedTopK": 2 },
{ "query": "they profess to know god but deny him by actions", "expectedEntryId": "cb_deny_works_001", "expectedTopK": 3 },
{ "query": "detestable disobedient unfit meaning", "expectedEntryId": "cb_deny_works_001", "expectedTopK": 4 },
{ "query": "episode about grace training us", "expectedEntryId": "6be7a644-9276-4797-a7bb-1422b170846c", "expectedTopK": 2 },
{ "query": "titus 2:11-12 episode", "expectedEntryId": "6be7a644-9276-4797-a7bb-1422b170846c", "expectedTopK": 12 },
{ "query": "how should i share faith with skeptical family", "expectedEntryId": "cb_faith_family_001", "expectedTopK": 2 },
{ "query": "evangelize skeptical relatives", "expectedEntryId": "cb_faith_family_001", "expectedTopK": 4 },
{ "query": "where can i listen to the podcast", "expectedEntryId": "cb_006", "expectedTopK": 2 },
{ "query": "how do i find verse by verse with nate on apple podcasts", "expectedEntryId": "cb_006", "expectedTopK": 3 },
{ "query": "what is verse by verse with nate podcast", "expectedEntryId": "cb_005", "expectedTopK": 2 },
{ "query": "tell me about the podcast", "expectedEntryId": "cb_005", "expectedTopK": 2 },
{ "query": "how do i approach a difficult passage", "expectedEntryId": "cb_002", "expectedTopK": 2 },
{ "query": "what should i do with confusing bible verses", "expectedEntryId": "cb_002", "expectedTopK": 3 },
{ "query": "prayer and quiet time advice", "expectedEntryId": "cb_008", "expectedTopK": 2 },
{ "query": "how to start a quiet time", "expectedEntryId": "cb_008", "expectedTopK": 3 },
{ "query": "what is episode 9 about", "expectedEntryId": "4e6ec41f-b083-45b9-9581-0dc91f092f66", "expectedTopK": 12 },
{ "query": "living between two appearings", "expectedEntryId": "4e6ec41f-b083-45b9-9581-0dc91f092f66", "expectedTopK": 2 },
{ "query": "what is episode 12", "expectedEntryId": "b68c5659-cc7e-42d2-ab24-a623b7404058", "expectedTopK": 2 },
{ "query": "the gospel in one paragraph", "expectedEntryId": "b68c5659-cc7e-42d2-ab24-a623b7404058", "expectedTopK": 2 },
{ "query": "what is episode 14 about", "expectedEntryId": "14ced988-1382-4c8d-a29e-b0f531580af1", "expectedTopK": 3 },
{ "query": "grace where it starts and where it ends", "expectedEntryId": "14ced988-1382-4c8d-a29e-b0f531580af1", "expectedTopK": 3 }
]
-102
View File
@@ -1,102 +0,0 @@
{
"questions": [
{
"id": "cb_001",
"firstName": "Nate",
"question": "What Bible translation do you use?",
"answer": "In the podcast, I primarily teach from the BSB (Berean Standard Bible). I may compare other translations at times, but BSB is my main teaching text in the Verse by Verse episodes.",
"isApproved": true,
"topic": "Bible Study",
"answeredAt": "2026-05-07T13:36:16.859Z"
},
{
"id": "cb_002",
"firstName": "Nate",
"question": "How do you approach a difficult or confusing Bible passage?",
"answer": "I always start by reading the surrounding context — a confusing verse often makes perfect sense once you zoom out a chapter or two. Then I look at who the author was, who they were writing to, and what was happening historically. A good study Bible or a commentary like Matthew Henry's can be a huge help. Most importantly, pray for understanding before you dive in. The Holy Spirit is your best guide.",
"isApproved": true,
"topic": "Bible Study"
},
{
"id": "cb_003",
"firstName": "Nate",
"question": "How do I stay consistent with daily Bible reading?",
"answer": "Start small and protect that time like an appointment. Even 10 minutes in the morning before checking your phone can be transformative over a year. A reading plan helps a lot — there are great ones on apps like YouVersion that break the whole Bible into daily chunks so you never have to decide what to read. And give yourself grace on the days you miss; guilt is not a good motivator. Just pick back up where you left off.",
"isApproved": true,
"topic": "Bible Study"
},
{
"id": "cb_007",
"firstName": "Nate",
"question": "How do I submit a question to Nate?",
"answer": "You can submit a question using the contact form at the bottom of this page. Select 'Bible Question' as the message type. Nate reads every question personally and answers the best ones on the site's Q&A section or in a future episode. There's no guarantee every question gets a public response, but all are read and appreciated!",
"isApproved": true,
"topic": "Podcast"
},
{
"id": "cb_faith_family_001",
"firstName": "Nate",
"question": "How can I share my faith with skeptical family?",
"answer": "Lead with relationship, not argument. Skeptical family members usually don't need more information — they need to see the faith lived out genuinely. Consistency, love, and patience over time speaks louder than any debate win.\n\nPray specifically and persistently. This is the most underrated strategy. God moves hearts in ways arguments never can.",
"isApproved": true,
"topic": "Faith & Life"
},
{
"id": "cb_blessed_hope_001",
"firstName": "Nate",
"question": "What is the blessed hope?",
"answer": "The blessed hope is a phrase from Titus 2:13 — \"as we await the blessed hope and glorious appearance of our great God and Savior Jesus Christ.\" In Episode 9, Nate breaks down the Greek: makaria elpis. Makaria is the same word used in the Beatitudes — a divinely favored condition. And elpis in the New Testament is not wishful thinking. Biblical hope is a confident expectation of something certain. Not a wish. An anchor.\n\nThe blessed hope is simply this: Jesus is coming back. Not maybe. He is coming back. And the person who really believes that is in a blessed condition even now, living toward a certainty that changes how everything feels today.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_who_is_titus_001",
"firstName": "Nate",
"question": "Who was Titus?",
"answer": "Titus was a Gentile — not Jewish — who came to faith through Paul's ministry. Paul calls him \"my true child in our common faith\" (Titus 1:4). He was one of Paul's closest and most trusted co-workers. In 2 Corinthians 7, Paul describes being comforted by Titus arriving during a difficult season. In chapter 8, Titus helped organize a relief offering for believers in Jerusalem. In Galatians 2, he appears as proof that the gospel is for everyone.\n\nPaul left Titus on the island of Crete with a difficult assignment: appoint elders, correct false teaching, and help a young church find its footing in a challenging culture. The letter of Titus is essentially his briefing.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_deny_works_001",
"firstName": "Nate",
"question": "What does it mean to deny God by your works?",
"answer": "Titus 1:16 says: \"They profess to know God, but by their actions they deny Him. They are detestable, disobedient, and unfit for any good deed.\"\n\nIn Episode 6, Nate explains this using three precise words Paul chose. Detestable — morally repulsive before God, regardless of religious appearance. Disobedient — not persuadable, resistant to truth even when confronted with it. Unfit — a Greek metalworking term for metal tested and found counterfeit. Religious on the surface, but no real substance underneath.\n\nThe warning cuts both ways: a person can know the right words, attend the right services, hold the right opinions — and still live in a way that functionally says God doesn't matter. Theology and life are inseparable. What you actually believe will show itself in how you actually live.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_grace_trains_001",
"firstName": "Nate",
"question": "What does grace actually train us to do?",
"answer": "Titus 2:11-12 says grace \"instructs us to renounce ungodliness and worldly passions, and to live sensible, upright, and godly lives.\"\n\nIn Episode 8, Nate points out that the Greek word for instructs is paideuō — the education of a child. Not classroom instruction but formation. Grace is the subject doing the teaching.\n\nGrace teaches two things: what to say no to (ungodliness and worldly passions) and what to say yes to — sensible (an ordered mind), upright (right in relationship with others), and godly (God genuinely at the center). Inward, outward, and upward.\n\nThe key insight: the pathway to saying no to sin is going deeper into grace, not trying harder. Grace properly understood reorients your desires — it doesn't just change the rules, it changes what you want. Grace is not a hall pass. It's a school you attend every day.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_leadership_titus1_001",
"firstName": "Nate",
"question": "What does Titus 1 teach about church leadership?",
"answer": "Paul's first instruction in Titus was to appoint elders — because a church without mature, qualified leadership is vulnerable. From Episode 4 (Titus 1:5-9):\n\nThe elder's home is exhibit A. Before looking at preaching or public presence, look at the household. If a man cannot lead his own family well, there's no reason to believe he'll lead God's family well. Integrity can't be compartmentalized.\n\nPaul gives five things an elder must NOT be: not self-absorbed, not quick-tempered, not given to drunkenness, not violent, not greedy. And six things he must BE: hospitable, a lover of good, self-controlled, upright, holy, and disciplined in sound doctrine.\n\nThis is not a job description for a CEO. Nate frames it as a picture of Christlike character — authority that flows from below, from a posture of submission and service, not dominance.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_titus347_summary_001",
"firstName": "Nate",
"question": "Give me a summary of Titus 3:4-7",
"answer": "Titus 3:4-7 is what Nate calls 'the gospel in one paragraph' — one of the most concentrated summaries of salvation anywhere in Paul's letters. Episode 12 breaks it down:\n\nAll three persons of the Trinity are present: God the Father is the initiating motivation — He is kind and full of love toward humanity. The Holy Spirit is the agent of new birth and ongoing renewal. Jesus Christ is the one through whom the Spirit is poured out, and verse 7 calls him 'our Savior.'\n\nThe hinge of the passage is verse 5: He saved us not by the righteous deeds we had done, but according to His mercy. Not partially. Not mostly. The basis is His mercy, not our merit — which means salvation rests on something that cannot change.\n\nVerse 7 gives us two results: justified (declared righteous in God's courtroom) and heirs (members of the family with a guaranteed inheritance). You are not just legally acquitted — you belong.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_christians_politics_001",
"firstName": "Nate",
"question": "How should Christians engage with politics and public life?",
"answer": "Titus 3:1-2 is Paul's answer, covered in Episode 10. He tells Titus to remind believers to submit to rulers and authorities, be obedient, be ready for every good work, malign no one, and be peaceable, gentle, and considerate to everyone.\n\nNate's key insight: Paul is not saying agree with every government decision. He's saying your engagement with public life should be marked by something recognizable as different. Submission, readiness to do good, and a peaceable presence are not just civic virtues — they are missionary strategies. The way you navigate public life either opens doors for the gospel or closes them.\n\nThe word for peaceable in Greek is amachous — literally non-combative. Paul doesn't say malign no one except your political opponents. He says no one. This is not weakness. It is the confidence of people who know how the story ends and don't need to win every argument to prove it.",
"isApproved": true,
"topic": "Faith & Life"
}
],
"updatedAt": "2026-05-07T13:36:16.859Z"
}
+1 -102
View File
@@ -1,102 +1 @@
{ []
"questions": [
{
"id": "cb_001",
"firstName": "Nate",
"question": "What Bible translation do you use?",
"answer": "In the podcast, I primarily teach from the BSB (Berean Standard Bible). I may compare other translations at times, but BSB is my main teaching text in the Verse by Verse episodes.",
"isApproved": true,
"topic": "Bible Study",
"answeredAt": "2026-05-07T13:36:16.859Z"
},
{
"id": "cb_002",
"firstName": "Nate",
"question": "How do you approach a difficult or confusing Bible passage?",
"answer": "I always start by reading the surrounding context — a confusing verse often makes perfect sense once you zoom out a chapter or two. Then I look at who the author was, who they were writing to, and what was happening historically. A good study Bible or a commentary like Matthew Henry's can be a huge help. Most importantly, pray for understanding before you dive in. The Holy Spirit is your best guide.",
"isApproved": true,
"topic": "Bible Study"
},
{
"id": "cb_003",
"firstName": "Nate",
"question": "How do I stay consistent with daily Bible reading?",
"answer": "Start small and protect that time like an appointment. Even 10 minutes in the morning before checking your phone can be transformative over a year. A reading plan helps a lot — there are great ones on apps like YouVersion that break the whole Bible into daily chunks so you never have to decide what to read. And give yourself grace on the days you miss; guilt is not a good motivator. Just pick back up where you left off.",
"isApproved": true,
"topic": "Bible Study"
},
{
"id": "cb_007",
"firstName": "Nate",
"question": "How do I submit a question to Nate?",
"answer": "You can submit a question using the contact form at the bottom of this page. Select 'Bible Question' as the message type. Nate reads every question personally and answers the best ones on the site's Q&A section or in a future episode. There's no guarantee every question gets a public response, but all are read and appreciated!",
"isApproved": true,
"topic": "Podcast"
},
{
"id": "cb_faith_family_001",
"firstName": "Nate",
"question": "How can I share my faith with skeptical family?",
"answer": "Lead with relationship, not argument. Skeptical family members usually don't need more information — they need to see the faith lived out genuinely. Consistency, love, and patience over time speaks louder than any debate win.\n\nPray specifically and persistently. This is the most underrated strategy. God moves hearts in ways arguments never can.",
"isApproved": true,
"topic": "Faith & Life"
},
{
"id": "cb_blessed_hope_001",
"firstName": "Nate",
"question": "What is the blessed hope?",
"answer": "The blessed hope is a phrase from Titus 2:13 — \"as we await the blessed hope and glorious appearance of our great God and Savior Jesus Christ.\" In Episode 9, Nate breaks down the Greek: makaria elpis. Makaria is the same word used in the Beatitudes — a divinely favored condition. And elpis in the New Testament is not wishful thinking. Biblical hope is a confident expectation of something certain. Not a wish. An anchor.\n\nThe blessed hope is simply this: Jesus is coming back. Not maybe. He is coming back. And the person who really believes that is in a blessed condition even now, living toward a certainty that changes how everything feels today.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_who_is_titus_001",
"firstName": "Nate",
"question": "Who was Titus?",
"answer": "Titus was a Gentile — not Jewish — who came to faith through Paul's ministry. Paul calls him \"my true child in our common faith\" (Titus 1:4). He was one of Paul's closest and most trusted co-workers. In 2 Corinthians 7, Paul describes being comforted by Titus arriving during a difficult season. In chapter 8, Titus helped organize a relief offering for believers in Jerusalem. In Galatians 2, he appears as proof that the gospel is for everyone.\n\nPaul left Titus on the island of Crete with a difficult assignment: appoint elders, correct false teaching, and help a young church find its footing in a challenging culture. The letter of Titus is essentially his briefing.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_deny_works_001",
"firstName": "Nate",
"question": "What does it mean to deny God by your works?",
"answer": "Titus 1:16 says: \"They profess to know God, but by their actions they deny Him. They are detestable, disobedient, and unfit for any good deed.\"\n\nIn Episode 6, Nate explains this using three precise words Paul chose. Detestable — morally repulsive before God, regardless of religious appearance. Disobedient — not persuadable, resistant to truth even when confronted with it. Unfit — a Greek metalworking term for metal tested and found counterfeit. Religious on the surface, but no real substance underneath.\n\nThe warning cuts both ways: a person can know the right words, attend the right services, hold the right opinions — and still live in a way that functionally says God doesn't matter. Theology and life are inseparable. What you actually believe will show itself in how you actually live.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_grace_trains_001",
"firstName": "Nate",
"question": "What does grace actually train us to do?",
"answer": "Titus 2:11-12 says grace \"instructs us to renounce ungodliness and worldly passions, and to live sensible, upright, and godly lives.\"\n\nIn Episode 8, Nate points out that the Greek word for instructs is paideuō — the education of a child. Not classroom instruction but formation. Grace is the subject doing the teaching.\n\nGrace teaches two things: what to say no to (ungodliness and worldly passions) and what to say yes to — sensible (an ordered mind), upright (right in relationship with others), and godly (God genuinely at the center). Inward, outward, and upward.\n\nThe key insight: the pathway to saying no to sin is going deeper into grace, not trying harder. Grace properly understood reorients your desires — it doesn't just change the rules, it changes what you want. Grace is not a hall pass. It's a school you attend every day.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_leadership_titus1_001",
"firstName": "Nate",
"question": "What does Titus 1 teach about church leadership?",
"answer": "Paul's first instruction in Titus was to appoint elders — because a church without mature, qualified leadership is vulnerable. From Episode 4 (Titus 1:5-9):\n\nThe elder's home is exhibit A. Before looking at preaching or public presence, look at the household. If a man cannot lead his own family well, there's no reason to believe he'll lead God's family well. Integrity can't be compartmentalized.\n\nPaul gives five things an elder must NOT be: not self-absorbed, not quick-tempered, not given to drunkenness, not violent, not greedy. And six things he must BE: hospitable, a lover of good, self-controlled, upright, holy, and disciplined in sound doctrine.\n\nThis is not a job description for a CEO. Nate frames it as a picture of Christlike character — authority that flows from below, from a posture of submission and service, not dominance.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_titus347_summary_001",
"firstName": "Nate",
"question": "Give me a summary of Titus 3:4-7",
"answer": "Titus 3:4-7 is what Nate calls 'the gospel in one paragraph' — one of the most concentrated summaries of salvation anywhere in Paul's letters. Episode 12 breaks it down:\n\nAll three persons of the Trinity are present: God the Father is the initiating motivation — He is kind and full of love toward humanity. The Holy Spirit is the agent of new birth and ongoing renewal. Jesus Christ is the one through whom the Spirit is poured out, and verse 7 calls him 'our Savior.'\n\nThe hinge of the passage is verse 5: He saved us not by the righteous deeds we had done, but according to His mercy. Not partially. Not mostly. The basis is His mercy, not our merit — which means salvation rests on something that cannot change.\n\nVerse 7 gives us two results: justified (declared righteous in God's courtroom) and heirs (members of the family with a guaranteed inheritance). You are not just legally acquitted — you belong.",
"isApproved": true,
"topic": "Titus"
},
{
"id": "cb_christians_politics_001",
"firstName": "Nate",
"question": "How should Christians engage with politics and public life?",
"answer": "Titus 3:1-2 is Paul's answer, covered in Episode 10. He tells Titus to remind believers to submit to rulers and authorities, be obedient, be ready for every good work, malign no one, and be peaceable, gentle, and considerate to everyone.\n\nNate's key insight: Paul is not saying agree with every government decision. He's saying your engagement with public life should be marked by something recognizable as different. Submission, readiness to do good, and a peaceable presence are not just civic virtues — they are missionary strategies. The way you navigate public life either opens doors for the gospel or closes them.\n\nThe word for peaceable in Greek is amachous — literally non-combative. Paul doesn't say malign no one except your political opponents. He says no one. This is not weakness. It is the confidence of people who know how the story ends and don't need to win every argument to prove it.",
"isApproved": true,
"topic": "Faith & Life"
}
],
"updatedAt": "2026-06-03T14:17:48.087Z"
}
-8
View File
@@ -1,8 +0,0 @@
{
"notesByUser": {
"e43e7217-be69-49c6-a65d-edd0e7745884": {
"1-1-2": "Test"
}
},
"updatedAt": "2026-05-12T15:51:54.402Z"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

+1 -9
View File
@@ -1,22 +1,14 @@
services: services:
portfolio: portfolio:
image: git.necloud.us/nate/siteforge:latest image: ghcr.io/nmemmert/siteforge:latest
pull_policy: always pull_policy: always
container_name: siteforge-app container_name: siteforge-app
ports: ports:
- "4173:4173" - "4173:4173"
environment: environment:
- PORT=4173 - PORT=4173
- SITEFORGE_DATA_DIR=/app/data
- ADMIN_PASSWORD=`generate a random password and set it here` - ADMIN_PASSWORD=`generate a random password and set it here`
- RESEND_API_KEY=`set your Resend API key here` - RESEND_API_KEY=`set your Resend API key here`
# Seafile integration for Nuggets host upload portal
# Set SEAFILE_TOKEN and SEAFILE_NUGGETS_REPO_ID in a .env file alongside this compose file
- SEAFILE_URL=https://seafile.necloud.us
- SEAFILE_TOKEN=${SEAFILE_TOKEN}
- SEAFILE_NUGGETS_REPO_ID=${SEAFILE_NUGGETS_REPO_ID}
# Upload notification email (defaults to RESEND_TO if not set)
- NUGGETS_NOTIFY_EMAIL=${NUGGETS_NOTIFY_EMAIL}
volumes: volumes:
- /media/ZimaOS-HD/AppData/siteforge/data:/app/data - /media/ZimaOS-HD/AppData/siteforge/data:/app/data
restart: unless-stopped restart: unless-stopped
+4 -191
View File
@@ -1,10 +1,11 @@
#!/bin/sh #!/bin/sh
set -e
DATA_DIR=${SITEFORGE_DATA_DIR:-/app/data} DATA_DIR=/app/data
SEED_DIR=/app/data-seed SEED_DIR=/app/data-seed
# Ensure the data directory exists (in case the volume was not mounted) # Ensure the data directory exists (in case the volume was not mounted)
mkdir -p "$DATA_DIR" || true mkdir -p "$DATA_DIR"
# Seed each default JSON file only when missing. # Seed each default JSON file only when missing.
# This runs on first boot or a fresh volume, but never overwrites existing data. # This runs on first boot or a fresh volume, but never overwrites existing data.
@@ -17,197 +18,9 @@ if [ -d "$SEED_DIR" ]; then
if [ ! -f "$target_file" ]; then if [ ! -f "$target_file" ]; then
echo "[siteforge] No $base_name found. Seeding from image defaults..." echo "[siteforge] No $base_name found. Seeding from image defaults..."
cp "$seed_file" "$target_file" || echo "[siteforge] Warning: could not seed $base_name — check volume permissions on $DATA_DIR" cp "$seed_file" "$target_file"
fi fi
done done
fi fi
# Merge newly added seeded platform links into existing admin content on upgrades
# without overwriting user-edited values already present in the live volume.
merge_seed_platform_links() {
target_file="$1"
seed_file="$2"
[ -f "$target_file" ] || return 0
[ -f "$seed_file" ] || return 0
node - "$target_file" "$seed_file" <<'NODE'
const fs = require('fs')
const [targetFile, seedFile] = process.argv.slice(2)
function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'))
}
function normalizeLinks(value) {
return Array.isArray(value) ? value.filter(item => item && typeof item === 'object') : []
}
const target = readJson(targetFile)
const seed = readJson(seedFile)
if (!target.siteContent || !seed.siteContent) process.exit(0)
const targetLinks = normalizeLinks(target.siteContent.customLinks)
const seedLinks = normalizeLinks(seed.siteContent.customLinks)
let changed = false
for (const seedLink of seedLinks) {
if (seedLink.placement !== 'platforms' || !seedLink.id || !seedLink.label || !seedLink.url) continue
const existingIndex = targetLinks.findIndex(link => link.id === seedLink.id)
if (existingIndex === -1) {
targetLinks.push(seedLink)
changed = true
continue
}
const existing = targetLinks[existingIndex]
const merged = { ...existing }
if (!merged.imageUrl && seedLink.imageUrl) {
merged.imageUrl = seedLink.imageUrl
changed = true
}
if (!merged.placement && seedLink.placement) {
merged.placement = seedLink.placement
changed = true
}
if (!merged.label && seedLink.label) {
merged.label = seedLink.label
changed = true
}
if (!merged.url && seedLink.url) {
merged.url = seedLink.url
changed = true
}
targetLinks[existingIndex] = merged
}
if (!changed) process.exit(0)
target.siteContent.customLinks = targetLinks
fs.writeFileSync(targetFile, `${JSON.stringify(target, null, 2)}\n`)
console.log(`[siteforge] Merged seeded platform links into ${targetFile}`)
NODE
}
merge_seed_platform_links "$DATA_DIR/admin-content.json" "$SEED_DIR/admin-content.json" || true
merge_seed_platform_links "$DATA_DIR/admin-content-draft.json" "$SEED_DIR/admin-content-draft.json" || true
# Backfill missing lesson releasedAt values from seeded defaults on upgrades.
# This keeps persistent volumes compatible with release-date access gating.
merge_seed_study_release_dates() {
target_file="$1"
seed_file="$2"
[ -f "$target_file" ] || return 0
[ -f "$seed_file" ] || return 0
node - "$target_file" "$seed_file" <<'NODE'
const fs = require('fs')
const [targetFile, seedFile] = process.argv.slice(2)
function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'))
}
function asArray(value) {
return Array.isArray(value) ? value : []
}
function mergeStudyDates(targetStudies, seedStudies) {
let changed = false
const seedBySlug = new Map()
for (const seedStudy of asArray(seedStudies)) {
if (!seedStudy || typeof seedStudy !== 'object') continue
if (typeof seedStudy.slug !== 'string' || !seedStudy.slug) continue
seedBySlug.set(seedStudy.slug, seedStudy)
}
for (const targetStudy of asArray(targetStudies)) {
if (!targetStudy || typeof targetStudy !== 'object') continue
if (typeof targetStudy.slug !== 'string' || !targetStudy.slug) continue
const seedStudy = seedBySlug.get(targetStudy.slug)
if (!seedStudy) continue
const seedSectionById = new Map()
for (const seedSection of asArray(seedStudy.sections)) {
if (!seedSection || typeof seedSection !== 'object') continue
if (typeof seedSection.id !== 'string' || !seedSection.id) continue
seedSectionById.set(seedSection.id, seedSection)
}
for (const targetSection of asArray(targetStudy.sections)) {
if (!targetSection || typeof targetSection !== 'object') continue
if (typeof targetSection.id !== 'string' || !targetSection.id) continue
if (typeof targetSection.releasedAt === 'string' && targetSection.releasedAt.trim()) continue
const seedSection = seedSectionById.get(targetSection.id)
if (!seedSection || typeof seedSection.releasedAt !== 'string' || !seedSection.releasedAt.trim()) continue
targetSection.releasedAt = seedSection.releasedAt
changed = true
}
}
return changed
}
function mergeLegacyColossiansDates(targetSections, seedSections) {
let changed = false
const seedById = new Map()
for (const seedSection of asArray(seedSections)) {
if (!seedSection || typeof seedSection !== 'object') continue
if (typeof seedSection.id !== 'string' || !seedSection.id) continue
seedById.set(seedSection.id, seedSection)
}
for (const targetSection of asArray(targetSections)) {
if (!targetSection || typeof targetSection !== 'object') continue
if (typeof targetSection.id !== 'string' || !targetSection.id) continue
if (typeof targetSection.releasedAt === 'string' && targetSection.releasedAt.trim()) continue
const seedSection = seedById.get(targetSection.id)
if (!seedSection || typeof seedSection.releasedAt !== 'string' || !seedSection.releasedAt.trim()) continue
targetSection.releasedAt = seedSection.releasedAt
changed = true
}
return changed
}
const target = readJson(targetFile)
const seed = readJson(seedFile)
if (!target.siteContent || !seed.siteContent) process.exit(0)
const targetContent = target.siteContent
const seedContent = seed.siteContent
const changedStudies = mergeStudyDates(targetContent.studies, seedContent.studies)
const changedLegacy = mergeLegacyColossiansDates(targetContent.colossiansStudySections, seedContent.colossiansStudySections)
if (!changedStudies && !changedLegacy) process.exit(0)
fs.writeFileSync(targetFile, `${JSON.stringify(target, null, 2)}\n`)
console.log(`[siteforge] Backfilled missing releasedAt values in ${targetFile}`)
NODE
}
merge_seed_study_release_dates "$DATA_DIR/admin-content.json" "$SEED_DIR/admin-content.json" || true
merge_seed_study_release_dates "$DATA_DIR/admin-content-draft.json" "$SEED_DIR/admin-content-draft.json" || true
export SITEFORGE_DATA_DIR="$DATA_DIR"
exec node server.js exec node server.js
-4
View File
@@ -19,9 +19,5 @@ export default defineConfig([
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
rules: {
// Reset-state-on-prop-change effects are used throughout; flagged for gradual cleanup rather than blocking lint.
'react-hooks/set-state-in-effect': 'warn',
},
}, },
]) ])
+5 -5
View File
@@ -11,17 +11,17 @@
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:title" content="Verse by Verse with Nate" /> <meta property="og:title" content="Verse by Verse with Nate" />
<meta property="og:description" content="A verse-by-verse Scripture podcast exploring God's Word with depth, clarity, and practical application." /> <meta property="og:description" content="A verse-by-verse Scripture podcast exploring God's Word with depth, clarity, and practical application." />
<meta property="og:image" content="https://versebyversewithnate.us/images/banner.png" /> <meta property="og:image" content="https://necloud.us/images/banner.png" />
<meta property="og:image:type" content="image/png" /> <meta property="og:image:type" content="image/png" />
<meta property="og:image:secure_url" content="https://versebyversewithnate.us/images/banner.png" /> <meta property="og:image:secure_url" content="https://necloud.us/images/banner.png" />
<meta property="og:image:alt" content="Verse by Verse with Nate podcast banner" /> <meta property="og:image:alt" content="Verse by Verse with Nate podcast banner" />
<meta property="og:locale" content="en_US" /> <meta property="og:locale" content="en_US" />
<meta property="og:url" content="https://versebyversewithnate.us" /> <meta property="og:url" content="https://necloud.us" />
<meta property="og:site_name" content="Verse by Verse with Nate" /> <meta property="og:site_name" content="Verse by Verse with Nate" />
<link rel="canonical" href="https://versebyversewithnate.us" /> <link rel="canonical" href="https://necloud.us" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,600;1,700&family=Lora:ital,wght@0,400;0,500;0,600;1,400;1,500&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;0,900;1,700&family=Barlow+Condensed:wght@300;400;500;700&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+694 -7312
View File
File diff suppressed because it is too large. Load diff
+5 -28
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "2.46.1", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -10,43 +10,21 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"start": "node --env-file=.env server.js", "start": "node --env-file=.env server.js",
"lint": "eslint .", "lint": "eslint .",
"test": "node --test test/*.test.js", "preview": "vite preview",
"preview": "vite preview" "chatbot:eval": "node scripts/evaluate-chatbot.mjs"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1135.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tiptap/core": "^3.31.4",
"@tiptap/pm": "^3.31.4",
"@tiptap/react": "^3.31.4",
"@tiptap/starter-kit": "^3.31.4",
"chart.js": "^4.4.0",
"docx": "^9.6.1",
"dompurify": "^3.4.16",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.4.1",
"fuse.js": "^7.4.2",
"mammoth": "^1.12.0",
"multer": "^2.3.0",
"otplib": "^13.4.0",
"prop-types": "^15.8.1",
"qrcode": "^1.5.4",
"react": "^19.2.4", "react": "^19.2.4",
"react-chartjs-2": "^5.2.0",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1", "react-router-dom": "^7.13.1",
"react-simple-maps": "^5.0.5",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"resend": "^6.10.0", "resend": "^6.10.0"
"tar": "^7.5.19"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0", "@vitejs/plugin-react": "^6.0.0",
@@ -57,7 +35,6 @@
"globals": "^17.4.0", "globals": "^17.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.56.1", "typescript-eslint": "^8.56.1",
"vite": "^8.0.0", "vite": "^8.0.0"
"vite-plugin-pwa": "^1.3.0"
} }
} }
-156
View File
@@ -1,156 +0,0 @@
# Finished Books — Episode Playlist Feature
## Goal
Add a proper **Finished Books** section where completed book studies live with a full ordered episode playlist. The Downloads page gets a minor label/copy update (Option A). A single "End Current Series" button in admin handles the full book transition in one step.
---
## User Flow (Public Site)
1. User clicks **Finished Books** in the nav
2. Lands on `/finished` — grid of completed book cards (image, title, description)
3. Clicks a book → `/finished/:id` — sees all episodes in numbered order, each with a player
4. Breadcrumb: `Finished Books → Titus`
**Episode Highlights** on the Episodes page only ever shows picks from the current book. When a book ends, highlights are cleared and replaced with picks from the new series automatically (see transition workflow below).
---
## Book Transition Workflow — "End Current Series" Button
A single button in admin (lives on the Current Series tab) that does all three transition steps at once:
### What it does
1. **Creates a Finished Book entry** from the current series data (title, image, description, season number) — immediately appears on `/finished`
2. **Clears Episode Highlights** — wipes `podcastFeaturedLinks` so the old season's picks are gone
3. **Opens a guided form** for the new series — prompts for new series title, image URL, and season number before saving
### The form flow
- User clicks **"End Current Series & Start New Book"**
- A confirmation modal appears with a summary: "This will archive [Titus] to Finished Books and clear Episode Highlights."
- Below the confirmation, a small form: New Series Title, New Series Image, New Season Number
- User fills it in and hits **Confirm**
- All three changes save together as one content update
### What the user still does manually after
- Go to Episode Highlights and add 3–5 hand-picked episodes from the new series (the section is now empty and ready)
---
## Changes Required
### 1. `server/routes/episodes.js` — Parse `itunes:season` from RSS
One line added to `parseRssItems` alongside the existing `itunes:episode` extraction:
```js
const season = (/<itunes:season>([\s\S]*?)<\/itunes:season>/.exec(block)?.[1] ?? '').trim()
```
Include `season` in the returned episode object.
### 2. `src/content.ts` — New `FinishedBook` type + field on `SiteContent`
Clean, purpose-built interface:
```ts
export interface FinishedBook {
id: string
title: string
description: string
imageUrl: string
season: number
}
```
Add `finishedBooks: FinishedBook[]` to `SiteContent` with default `[]`.
### 3. `src/App.tsx` — New `useEpisodesForBook` hook
Fetches `/api/episodes/all` and filters by season number, sorted ascending:
```js
const filtered = (data.episodes ?? [])
.filter(ep => ep.season === String(book.season))
.sort((a, b) => parseInt(a.episode) - parseInt(b.episode))
```
### 4. `src/App.tsx` — Nav: "Episodes" dropdown replacing flat link
"Episodes" becomes a dropdown parent with two children: **Current Series** (`/episodes`) and **Finished Books** (`/finished`). The flat `<NavLink to="/episodes">` is replaced with a dropdown component. Everything else in the nav stays flat and unchanged.
**Desktop behavior:**
- "Episodes ▾" shows on hover (or focus), revealing a small popover menu below with the two links
- Parent label stays gold/active when either child route is active
- Implemented with CSS `:hover` + `:focus-within` on a wrapper — no JS state needed for desktop
**Mobile behavior (under ~540px):**
- The mobile nav already collapses to a full-width vertical list with `max-height` animation
- "Episodes" becomes a top-level row with an expand arrow (chevron)
- Tapping it toggles a sub-list showing "Current Series" and "Finished Books" indented beneath it
- Implemented with a small React `useState` toggle on the dropdown item — consistent with how `menuOpen` already works
- Sub-items get slightly smaller text and left padding to show hierarchy
- The `max-height: 500px` on `.header-nav--open` will need to increase to `600px` to accommodate the expanded sub-items
**New elements needed:**
- `NavDropdown` component (or inline JSX) wrapping the two episode links
- CSS classes: `.nav-dropdown`, `.nav-dropdown-menu`, `.nav-dropdown-item`, `.nav-dropdown--open` (mobile only)
- Mobile sub-item styles: indented, smaller text, no bottom border on last child before parent closes
**Footer:** Add a flat "Finished Books" link alongside the existing footer nav links — no dropdown needed there.
### 5. `src/App.tsx` — Two new routes
```tsx
<Route path="/finished" element={<FinishedBooksPage content={content} />} />
<Route path="/finished/:id" element={<FinishedSeriesPage content={content} />} />
```
### 6. `src/App.tsx` — Two new page components
**`FinishedBooksPage`** — grid of `finishedBooks` cards (image, title, description), each linking to `/finished/:id`. Friendly empty state if no books yet.
**`FinishedSeriesPage`** — uses `useEpisodesForBook`, renders episodes sorted ascending by episode number as a numbered playlist. Each row: episode number, title, duration, `EpisodeAudioPlayer`.
### 7. `src/AdminPage.tsx` — "End Current Series" button on Current Series tab
- Button labeled **"End Current Series & Start New Book"**
- Opens an inline confirmation + new series form (not a browser `confirm()` — a proper UI panel)
- On confirm: creates `FinishedBook` from current series data, clears `podcastFeaturedLinks`, updates series fields with new book info
- All saved as one atomic content update
### 8. `src/AdminPage.tsx` — Finished Books management section
Separate tab for viewing and managing the finished books list (in case you need to edit or remove one):
- Shows each finished book as a collapsible card
- Edit title, description, image, season number
- Remove button
- "Add Finished Book" button for manually adding one without going through the transition wizard
### 9. CSS — Playlist and finished books grid styles
Reuse existing episode-list and episode-card patterns where possible.
### 10. `src/App.tsx` — Downloads page copy tweak (Option A, low priority)
Minor label changes only — no structural change:
- Eyebrow becomes "Current Study"
- Heading becomes `{content.seriesTitle} — Study Guide`
- Everything else (download form, Amazon button, library) stays identical
---
## Prerequisite — Verify RSS Season Tags
Before starting, confirm Titus episodes have `<itunes:season>` tags and what number they use:
```
curl -s https://anchor.fm/s/11068d290/podcast/rss | grep -o '<itunes:season>[^<]*</itunes:season>' | head -5
```
If seasons are missing from the RSS, the fallback is an `episodeRange: { from, to }` field on `FinishedBook` instead of `season`.
---
## Files Touched Summary
| File | What changes |
|---|---|
| `server/routes/episodes.js` | Parse `itunes:season` into episode objects |
| `src/content.ts` | New `FinishedBook` interface + `finishedBooks` field on `SiteContent` |
| `src/App.tsx` | New hook, nav links, 2 routes, 2 page components, Downloads copy tweak |
| `src/AdminPage.tsx` | "End Current Series" transition button + Finished Books management tab |
| CSS | Playlist page + finished books grid styles |
---
## Order of Work
1. Run the RSS curl check to confirm season tags (5 min)
2. `content.ts` — add `FinishedBook` type and `finishedBooks` field
3. `server/routes/episodes.js` — parse season from RSS
4. Admin — "End Current Series" transition button + Finished Books management tab
5. Public pages — nav link, routes, `FinishedBooksPage`, `FinishedSeriesPage`
6. Downloads copy tweak (last, lowest priority)
-26
View File
@@ -1,26 +0,0 @@
# Siteforge TODO
Open gaps found in the beta audit (2026-10-09). Items already shipped are in git history (v2.10.0 – v2.10.2).
## Security
- [ ] **Email 2FA codes use `Math.random()`** — switch `generateEmailOtp` in `server/study-helpers.js` to `crypto.randomInt`; compare hashes with `timingSafeEqual`.
- [ ] **TOTP replay** — `verifyTotpCode` in `server/auth.js` accepts a code again within its ±1 step window. Record the last accepted time step per secret (admin + study users) and reject anything at or before it.
- [ ] **`unhandledRejection` / `uncaughtException` handlers** in `server.js` — Node 22 exits on an unhandled rejection; log and (for rejections) keep running.
- [ ] **Scrub old user data from git history** — `data/study-users.json` (old SHA-256 password hashes), study notes/progress etc. were committed before being untracked in v2.10.0. Needs a history rewrite (`git filter-repo`) + force-push; old hashes remain recoverable until then.
- [ ] **CSP `'unsafe-inline'` in `script-src`** — needs a nonce setup for the Vite build to remove.
## Frontend / performance
- [ ] **Code splitting** — no `lazy()`/`Suspense` anywhere; one 1.7 MB JS bundle includes the 7,000-line `AdminPage`. Lazy-load `AdminPage` and the study pages.
- [ ] **React error boundary** — a render error currently white-screens the page; add a top-level boundary with a fallback.
- [ ] **Per-route SEO / link previews** — OG tags are fixed in `index.html` and titles are set client-side (`usePageMeta`), so non-JS crawlers (iMessage, Facebook, Slack) show the generic preview for `/episodes/:id` and study lesson URLs. Inject per-route title/description/OG tags server-side.
- [ ] **Accessibility audit** — run axe (contrast, focus order, modal dialogs, image alt text).
## Maintenance
- [ ] **Split the large files** — `AdminPage.tsx` (~7,000 lines), `studyPages.tsx` (~4,200), `App.tsx` (~3,600).
- [ ] **Clear the 11 `react-hooks/set-state-in-effect` lint warnings** (rule downgraded to `warn` in v2.10.0), then restore it to `error`.
- [ ] **Remaining npm audit findings** — `d3-color` (high, via `react-simple-maps`) and `sprintf-js` (moderate, via `mammoth`; fix is a breaking downgrade).
- [ ] **CI installs with `--legacy-peer-deps`** — resolve the tiptap peer-dependency conflict so plain `npm ci` works.
- [ ] **Resend webhook `?token=` query form** — in logs/URLs; kept because Resend can't send custom headers. Revisit if Resend adds header support or switch to svix signature verification.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="184" height="48" viewBox="0 0 184 48">
<rect width="184" height="48" rx="6" fill="#1877F2"/>
<g transform="translate(11,11)">
<circle cx="13" cy="13" r="12" fill="rgba(255,255,255,0.16)"/>
<path fill="#ffffff" d="M17.755 13.037C17.755 10.385 15.617 8.237 12.98 8.237c-2.638 0-4.776 2.148-4.776 4.8 0 2.396 1.744 4.383 4.028 4.743v-3.36H11.01v-1.383h1.222v-1.054c0-1.215.719-1.887 1.82-1.887.527 0 1.078.094 1.078.094v1.193h-.608c-.598 0-.784.373-.784.755v.899h1.334l-.213 1.383h-1.121v3.36c2.284-.36 4.017-2.347 4.017-4.743z"/>
</g>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="rgba(255,255,255,0.82)" font-size="9.5" x="52" y="19">Follow on</text>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#ffffff" font-size="16" font-weight="bold" x="52" y="36">Facebook</text>
</svg>

Before

Width:  |  Height:  |  Size: 891 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

-12
View File
@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="210" height="48" viewBox="0 0 210 48">
<rect width="210" height="48" rx="6" fill="#131921"/>
<!-- Amazon Music icon: headphone/music note style - simple "a" with music mark -->
<g transform="translate(11,11)">
<!-- Amazon arrow swoosh shape -->
<circle cx="13" cy="13" r="12" fill="#00A8E1"/>
<!-- Simplified headphone icon in white -->
<path fill="#ffffff" d="M13 4C8.03 4 4 8.03 4 13v1h2v-1c0-3.87 3.13-7 7-7s7 3.13 7 7v1h2v-1c0-4.97-4.03-9-9-9zm-3 10H8c-.55 0-1 .45-1 1v3c0 .55.45 1 1 1h2c.55 0 1-.45 1-1v-3c0-.55-.45-1-1-1zm6 0h-2c-.55 0-1 .45-1 1v3c0 .55.45 1 1 1h2c.55 0 1-.45 1-1v-3c0-.55-.45-1-1-1z"/>
</g>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#b3b3b3" font-size="9.5" x="52" y="19">Listen on</text>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#ffffff" font-size="15" font-weight="bold" x="52" y="36">Amazon Music</text>
</svg>

Before

Width:  |  Height:  |  Size: 972 B

-11
View File
@@ -1,11 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="183" height="48" viewBox="0 0 183 48">
<rect width="183" height="48" rx="6" fill="#1a1a1a"/>
<!-- Castbox icon: headphones/podcast icon in orange -->
<g transform="translate(11,11)">
<circle cx="13" cy="13" r="12" fill="#F55223"/>
<!-- Headphones shape in white -->
<path fill="#ffffff" d="M13 5C8.58 5 5 8.58 5 13v.5H7V13c0-3.31 2.69-6 6-6s6 2.69 6 6v.5h2V13c0-4.42-3.58-8-8-8zm-3.5 9H7c-.55 0-1 .45-1 1v2.5c0 .55.45 1 1 1h2.5c.55 0 1-.45 1-1V15c0-.55-.45-1-1-1zm7 0h-2.5c-.55 0-1 .45-1 1v2.5c0 .55.45 1 1 1H17c.55 0 1-.45 1-1V15c0-.55-.45-1-1-1z"/>
</g>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#b3b3b3" font-size="9.5" x="52" y="19">Listen on</text>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#ffffff" font-size="16" font-weight="bold" x="52" y="36">Castbox</text>
</svg>

Before

Width:  |  Height:  |  Size: 904 B

-15
View File
@@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="195" height="48" viewBox="0 0 195 48">
<rect width="195" height="48" rx="6" fill="#F43E37"/>
<!-- Pocket Casts logo: circle with play-in-wave icon -->
<g transform="translate(11,11)">
<circle cx="13" cy="13" r="12" fill="rgba(0,0,0,0.25)"/>
<!-- Outer arc -->
<path fill="#ffffff" d="M13 2C7 2 2 7 2 13s5 11 11 11 11-5 11-11S19 2 13 2zm0 19c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/>
<!-- Inner arc -->
<path fill="#ffffff" d="M13 6c-3.87 0-7 3.13-7 7s3.13 7 7 7 7-3.13 7-7-3.13-7-7-7zm0 11c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z"/>
<!-- Center dot -->
<circle cx="13" cy="13" r="2.5" fill="#ffffff"/>
</g>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="rgba(255,255,255,0.8)" font-size="9.5" x="52" y="19">Listen on</text>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#ffffff" font-size="14.5" font-weight="bold" x="52" y="36">Pocket Casts</text>
</svg>

Before

Width:  |  Height:  |  Size: 1019 B

-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="48" viewBox="0 0 180 48">
<rect width="180" height="48" rx="6" fill="#191414"/>
<!-- Spotify icon (24x24 viewBox path, scaled to fit 26x26 at offset 11,11) -->
<g transform="translate(11,11) scale(1.083)">
<path fill="#1DB954" d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/>
</g>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#b3b3b3" font-size="9.5" x="48" y="19">Listen on</text>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#ffffff" font-size="16" font-weight="bold" x="48" y="36">Spotify</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

-12
View File
@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="190" height="48" viewBox="0 0 190 48">
<rect width="190" height="48" rx="6" fill="#0f0f0f"/>
<!-- YouTube icon: red rounded rect with white play triangle -->
<g transform="translate(11,11)">
<!-- YouTube red background rect -->
<rect x="1" y="4.5" width="24" height="17" rx="4" fill="#FF0000"/>
<!-- White play triangle -->
<polygon points="10,9 20,13 10,17" fill="#ffffff"/>
</g>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#b3b3b3" font-size="9.5" x="52" y="19">Watch on</text>
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#ffffff" font-size="16" font-weight="bold" x="52" y="36">YouTube</text>
</svg>

Before

Width:  |  Height:  |  Size: 732 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

-9
View File
@@ -1,9 +0,0 @@
User-agent: *
Allow: /
Disallow: /admin
Disallow: /api/
Disallow: /thanks
Disallow: /subscribe/thanks
Sitemap: https://versebyversewithnate.us/sitemap.xml
File diff suppressed because it is too large. Load diff
-69
View File
@@ -1,69 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://versebyversewithnate.us/</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/start-here</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/episodes</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/study</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>weekly</changefreq>
<priority>0.85</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/resources</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/questions</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/about</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/contact</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/subscribe</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/privacy</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://versebyversewithnate.us/terms</loc>
<lastmod>2026-06-01</lastmod>
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
</urlset>
+92
View File
@@ -0,0 +1,92 @@
import { execSync } from 'child_process'
import { readFileSync, writeFileSync } from 'fs'
import { randomUUID } from 'crypto'
const BASE = "/Users/nate.emmert/Documents/github/Siteforge/Verse by Verse with Nate Complete Series"
const CHATBOT_FILE = "/Users/nate.emmert/Documents/github/Siteforge/data/chatbot-content.json"
const FILES = [
{ file: `${BASE}/Done/Verse_by_Verse_with_Nate_Episode02.docx`, ep: 2 },
{ file: `${BASE}/Done/Verse_by_Verse_with_Nate_Episode03.docx`, ep: 3 },
{ file: `${BASE}/Done/Verse_by_Verse_with_Nate_Episode04_Updated.docx`, ep: 4 },
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode05.docx`, ep: 5 },
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode06_expanded.docx`, ep: 6 },
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode07.docx`, ep: 7 },
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode08.docx`, ep: 8 },
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode09.docx`, ep: 9 },
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode10.docx`, ep: 10 },
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode11.docx`, ep: 11 },
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode12.docx`, ep: 12 },
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode13.docx`, ep: 13 },
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode14.docx`, ep: 14 },
]
function extractText(filePath) {
const xml = execSync(`unzip -p "${filePath}" word/document.xml 2>/dev/null`, { encoding: 'utf8' })
return xml
.replace(/<[^>]+>/g, ' ')
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
function parseEpisode(raw, epNum) {
// Extract episode subtitle and passage reference from header
const headerMatch = raw.match(/EPISODE\s+\d+\s*[—\-\u2013\u2014]+\s*(.+?)\s+(Titus\s+[\d:]+(?:\s*[\-\u2013\u2014]+\s*[\d:]+)?)\s*·/i)
const subtitle = headerMatch ? headerMatch[1].trim().replace(/\s+/g, ' ') : ''
const passageRef = headerMatch ? headerMatch[2].trim() : 'Titus'
const episodeTitle = `Episode ${epNum} — ${subtitle || 'Verse by Verse with Nate'}`
// Find where the actual teaching content starts
let contentStart = raw.indexOf('SEGMENT 1')
if (contentStart === -1) contentStart = raw.indexOf('WHO WAS PAUL')
if (contentStart === -1) contentStart = raw.indexOf('COLD OPEN')
if (contentStart === -1) contentStart = 400
const rawContent = raw.slice(contentStart, contentStart + 4000)
const content = rawContent
.replace(/\[[^\]]{0,100}\]/g, '') // remove [stage directions]
.replace(/[✝🎙️📖💬🧠💡🔑✅◀▶]/gu, '') // remove emoji
.replace(/SEGMENT\s+\d+\s*[—\-\u2013]+\s*/g, '\n\n') // turn SEGMENT headers into breaks
.replace(/\s{2,}/g, ' ')
.trim()
// Build keyword list
const verseRefs = [...new Set((raw.match(/Titus\s+\d+:\d+/g) || []))].slice(0, 5).map(k => k.toLowerCase())
const titleWords = subtitle.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(w => w.length > 3)
const keywords = [...new Set([
'titus', `episode ${epNum}`, passageRef.toLowerCase(),
...verseRefs, ...titleWords
])].slice(0, 20)
return {
id: randomUUID(),
type: 'episode',
title: `${episodeTitle} (${passageRef})`,
content: content.slice(0, 3900),
keywords,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
}
// Load existing entries (keep the 8 hand-written ones)
const existing = JSON.parse(readFileSync(CHATBOT_FILE, 'utf8'))
// Remove any previously generated episode entries to avoid duplication
const baseEntries = existing.filter(e => e.type !== 'episode')
const newEntries = []
for (const { file, ep } of FILES) {
try {
const raw = extractText(file)
const entry = parseEpisode(raw, ep)
newEntries.push(entry)
console.log(`✓ Ep ${ep}: ${entry.title.slice(0, 80)}`)
} catch (err) {
console.error(`✗ Ep ${ep}: ${err.message}`)
}
}
const combined = [...baseEntries, ...newEntries]
writeFileSync(CHATBOT_FILE, JSON.stringify(combined, null, 2), 'utf8')
console.log(`\nDone. ${newEntries.length} episode entries added. Total: ${combined.length}`)
+137
View File
@@ -0,0 +1,137 @@
import fs from 'node:fs/promises'
import path from 'node:path'
const root = process.cwd()
const dataPath = path.join(root, 'data', 'chatbot-content.json')
const evalPath = path.join(root, 'data', 'chatbot-eval.json')
const STOP_WORDS = new Set([
'a','an','the','is','are','was','were','be','been','being','have','has','had','do','does','did',
'will','would','could','should','may','might','shall','can','i','you','he','she','it','we','they',
'me','him','her','us','them','my','your','his','its','our','their','this','that','these','those',
'and','but','or','nor','so','yet','for','of','in','on','at','to','from','with','by','about',
'what','how','why','when','where','who','which','if','then','than','as','just','not',
])
const TOKEN_ALIASES = {
bible: ['translation', 'version', 'scripture', 'bsb', 'berean'],
translation: ['version', 'bsb', 'berean', 'bible'],
version: ['translation', 'bsb', 'berean', 'bible'],
elders: ['elder', 'leadership', 'leaders', 'overseer', 'pastor'],
leadership: ['elders', 'elder', 'overseer', 'leaders'],
grace: ['salvation', 'saved', 'godliness', 'mercy'],
salvation: ['saved', 'grace', 'mercy', 'gospel'],
saved: ['salvation', 'grace', 'mercy', 'gospel'],
hope: ['blessed', 'appearing', 'return', 'coming'],
politics: ['public', 'government', 'authorities'],
}
function tokenize(text) {
return text
.toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ')
.split(/\s+/)
.filter(token => token.length > 2 && !STOP_WORDS.has(token))
}
function expandTokens(tokens) {
const expanded = new Set(tokens)
for (const token of tokens) {
const aliases = TOKEN_ALIASES[token] ?? []
for (const alias of aliases) {
for (const aliasToken of tokenize(alias)) expanded.add(aliasToken)
}
}
return [...expanded]
}
function literalTerms(text) {
return [...new Set(
text
.toLowerCase()
.replace(/[^a-z0-9:\-\s']/g, ' ')
.split(/\s+/)
.map(term => term.trim())
.filter(term => term.length >= 2 && !STOP_WORDS.has(term)),
)]
}
function scoreEntry(entry, query) {
const indexText = `${entry.title} ${entry.content} ${entry.keywords.join(' ')}`.toLowerCase()
const queryTokens = expandTokens(tokenize(query))
const terms = literalTerms(query)
let score = 0
for (const token of queryTokens) {
if (entry.title.toLowerCase().includes(token)) score += 4
else if (indexText.includes(token)) score += 2
}
for (const term of terms) {
if (entry.title.toLowerCase().includes(term)) score += 2
else if (indexText.includes(term)) score += 1
}
const phrase = query.toLowerCase().replace(/[^a-z0-9:\-\s']/g, ' ').replace(/\s+/g, ' ').trim()
if (phrase.length > 6) {
if (entry.title.toLowerCase().includes(phrase)) score += 10
else if (indexText.includes(phrase)) score += 6
}
return score
}
async function main() {
const [contentRaw, evalRaw] = await Promise.all([
fs.readFile(dataPath, 'utf8'),
fs.readFile(evalPath, 'utf8'),
])
const entries = JSON.parse(contentRaw)
const tests = JSON.parse(evalRaw)
let pass = 0
const failures = []
for (const test of tests) {
const ranked = entries
.map(entry => ({ entry, score: scoreEntry(entry, test.query) }))
.sort((a, b) => b.score - a.score)
const topK = ranked.slice(0, test.expectedTopK)
const hit = topK.some(item => item.entry.id === test.expectedEntryId)
if (hit) {
pass += 1
continue
}
failures.push({
query: test.query,
expectedEntryId: test.expectedEntryId,
expectedTopK: test.expectedTopK,
actualTop: topK.map(item => ({ id: item.entry.id, title: item.entry.title, score: item.score })),
})
}
const total = tests.length
const pct = ((pass / total) * 100).toFixed(1)
console.log(`Chatbot eval: ${pass}/${total} (${pct}%) passed`)
if (failures.length > 0) {
console.log('\nFailures:')
for (const failure of failures) {
console.log(`- Query: ${failure.query}`)
console.log(` Expected: ${failure.expectedEntryId} in top ${failure.expectedTopK}`)
console.log(` Actual: ${failure.actualTop.map(item => `${item.id} (${item.score})`).join(', ')}`)
}
process.exitCode = 1
}
}
main().catch(error => {
console.error(error)
process.exitCode = 1
})
+26
View File
@@ -0,0 +1,26 @@
import { readFileSync, writeFileSync } from 'fs'
const path = '/Users/nate.emmert/Documents/github/Siteforge/data/chatbot-content.json'
const data = JSON.parse(readFileSync(path, 'utf8'))
const fixes = {
2: { title: 'Episode 2 — Introduction to Titus (Background & Overview)', extra: ['introduction', 'background', 'overview', 'crete', 'letter'] },
5: { title: 'Episode 5 — The Danger of Empty Words (Titus 1:10–13a)', extra: ['danger', 'empty', 'words', 'false', 'teacher', 'titus 1:10', 'titus 1:13'] },
6: { title: 'Episode 6 — Words That Deny What We Claim to Believe (Titus 1:13b–16)', extra: ['deny', 'claim', 'believe', 'titus 1:13', 'titus 1:16'] },
14: { title: 'Episode 14 — Grace: Where It Starts and Where It Ends (Titus 3:12–15)', extra: ['grace', 'starts', 'ends', 'review', 'titus 3:12', 'titus 3:15'] },
}
let count = 0
for (const entry of data) {
const m = entry.title.match(/^Episode (\d+)/)
if (!m) continue
const ep = Number(m[1])
if (fixes[ep]) {
entry.title = fixes[ep].title
entry.keywords = [...new Set([...entry.keywords, ...fixes[ep].extra])].slice(0, 20)
count++
}
}
writeFileSync(path, JSON.stringify(data, null, 2), 'utf8')
console.log(`Fixed ${count} entries. Total: ${data.length}`)
-148
View File
@@ -1,148 +0,0 @@
// Generates the /salvation audio from the narration files in scripts/salvation/ using ElevenLabs.
// There are three tracks: the case, then the listener chooses Guilty or Not guilty.
//
// node --env-file=.env scripts/generate-salvation-audio.mjs --list-voices # find your voice id
// node --env-file=.env scripts/generate-salvation-audio.mjs --sample # ~40 seconds, to test the voice (cheap)
// node --env-file=.env scripts/generate-salvation-audio.mjs # generate all three MP3s
// node --env-file=.env scripts/generate-salvation-audio.mjs --only=guilty # just one: case | guilty | not-guilty
//
// Needs, in your local .env (never commit it):
// ELEVENLABS_API_KEY=...
// ELEVENLABS_VOICE_ID=... (from --list-voices, or your cloned voice)
// Optional:
// ELEVENLABS_MODEL=eleven_multilingual_v2
// ELEVENLABS_STABILITY=0.5 (lower = more expressive, higher = steadier)
// ELEVENLABS_SPEED=1
//
// Output goes to audio-out/ (git-ignored). Upload the MP3s in Admin → Asset Manager, then paste their
// URLs into Admin → Gospel Page.
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const API = 'https://api.elevenlabs.io/v1'
const apiKey = process.env.ELEVENLABS_API_KEY
const voiceId = process.env.ELEVENLABS_VOICE_ID
const model = process.env.ELEVENLABS_MODEL || 'eleven_multilingual_v2'
const MAX_CHARS = 4500
let totalCost = 0
function fail(message) {
console.error(`\n${message}\n`)
process.exit(1)
}
if (!apiKey) fail('ELEVENLABS_API_KEY is not set. Add it to your local .env file (not to chat or to git).')
async function listVoices() {
const res = await fetch(`${API}/voices`, { headers: { 'xi-api-key': apiKey } })
if (!res.ok) fail(`Could not list voices (${res.status}). Check the API key.`)
const { voices = [] } = await res.json()
console.log('\nYour voices (name: voice id)\n')
for (const v of voices) console.log(` ${v.name}${v.category ? ` [${v.category}]` : ''}: ${v.voice_id}`)
console.log('\nPut the one you want in .env as ELEVENLABS_VOICE_ID=...\n')
}
// Split on paragraph boundaries so each request stays under MAX_CHARS and never cuts a sentence.
function chunkText(text) {
const paragraphs = text.split(/\n{2,}/).map(p => p.trim()).filter(Boolean)
const chunks = []
let current = ''
for (const p of paragraphs) {
if (current && current.length + p.length + 2 > MAX_CHARS) { chunks.push(current); current = '' }
current = current ? `${current}\n\n${p}` : p
}
if (current) chunks.push(current)
return chunks
}
async function speak(text, previous, next) {
const res = await fetch(`${API}/text-to-speech/${voiceId}?output_format=mp3_44100_128`, {
method: 'POST',
headers: { 'xi-api-key': apiKey, 'Content-Type': 'application/json', Accept: 'audio/mpeg' },
body: JSON.stringify({
text,
model_id: model,
voice_settings: {
stability: Number(process.env.ELEVENLABS_STABILITY ?? 0.5),
similarity_boost: 0.75,
style: 0,
use_speaker_boost: true,
speed: Number(process.env.ELEVENLABS_SPEED ?? 1),
},
...(previous ? { previous_text: previous.slice(-600) } : {}),
...(next ? { next_text: next.slice(0, 600) } : {}),
}),
})
if (!res.ok) {
const detail = await res.text().catch(() => '')
fail(`ElevenLabs returned ${res.status}.\n${detail.slice(0, 400)}`)
}
const cost = res.headers.get('character-cost')
if (cost) totalCost += Number(cost) || 0
return Buffer.from(await res.arrayBuffer())
}
const TRACKS = [
{ id: 'case', file: 'narration-1-case.txt', out: 'salvation-case.mp3' },
{ id: 'guilty', file: 'narration-2-guilty.txt', out: 'salvation-guilty.mp3' },
{ id: 'not-guilty', file: 'narration-3-not-guilty.txt', out: 'salvation-not-guilty.mp3' },
]
async function renderTrack(text, outPath) {
const chunks = chunkText(text)
const parts = []
for (let i = 0; i < chunks.length; i += 1) {
process.stdout.write(` part ${i + 1}/${chunks.length}… `)
parts.push(await speak(chunks[i], chunks[i - 1], chunks[i + 1]))
console.log('done')
}
await writeFile(outPath, Buffer.concat(parts))
}
async function main() {
if (process.argv.includes('--list-voices')) { await listVoices(); return }
if (!voiceId) fail('ELEVENLABS_VOICE_ID is not set. Run with --list-voices to find it, then add it to .env.')
const outDir = path.join(ROOT, 'audio-out')
await mkdir(outDir, { recursive: true })
if (process.argv.includes('--sample')) {
const full = (await readFile(path.join(ROOT, 'scripts/salvation', TRACKS[0].file), 'utf8')).trim()
// Whole paragraphs only, so the sample never stops mid-sentence (about 700 characters).
let sample = ''
for (const para of full.split(/\n{2,}/)) {
if (sample && sample.length + para.length > 750) break
sample = sample ? `${sample}\n\n${para}` : para
}
// Don't end on a lone heading like "The Accuser."
const lastBreak = sample.lastIndexOf('\n\n')
if (lastBreak > 0 && sample.length - lastBreak < 40) sample = sample.slice(0, lastBreak)
console.log(`\nSample: ${sample.length} characters (model ${model}).\n`)
const out = path.join(outDir, 'salvation-sample.mp3')
await renderTrack(sample, out)
if (totalCost) console.log(`\nElevenLabs reported ${totalCost.toLocaleString()} credits used.`)
console.log(`\nSaved ${out}\nIf the voice sounds right, run again without --sample.\n`)
return
}
const only = (process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1]
const tracks = only ? TRACKS.filter(t => t.id === only) : TRACKS
if (!tracks.length) fail(`Unknown --only value "${only}". Use: case, guilty or not-guilty.`)
const texts = []
for (const t of tracks) texts.push((await readFile(path.join(ROOT, 'scripts/salvation', t.file), 'utf8')).trim())
const chars = texts.reduce((n, t) => n + t.length, 0)
console.log(`\n${tracks.length} track(s), ${chars.toLocaleString()} characters in total (model ${model}).`)
console.log('This uses about that many ElevenLabs credits.\n')
for (const [i, t] of tracks.entries()) {
console.log(` ${t.id} (${texts[i].length.toLocaleString()} characters)`)
await renderTrack(texts[i], path.join(outDir, t.out))
}
if (totalCost) console.log(`\nElevenLabs reported ${totalCost.toLocaleString()} credits used.`)
console.log(`\nSaved to ${outDir}:\n${tracks.map(t => ` ${t.out}`).join('\n')}\nListen to each one before you upload it. Check the Scripture names and references.\n`)
}
main().catch(err => fail(err?.message ?? String(err)))
+194
View File
@@ -0,0 +1,194 @@
import { execFileSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { promises as fs } from 'node:fs'
import path from 'node:path'
const ROOT = '/Users/nate.emmert/Documents/github/Siteforge'
const DOCS_DIR = path.join(ROOT, 'Verse by Verse with Nate Complete Series')
const CHATBOT_FILE = path.join(ROOT, 'data', 'chatbot-content.json')
const STOP_WORDS = new Set([
'the', 'and', 'for', 'that', 'with', 'this', 'from', 'your', 'you', 'are', 'but', 'not', 'have',
'has', 'was', 'were', 'his', 'her', 'our', 'their', 'into', 'about', 'what', 'when', 'where',
'which', 'will', 'just', 'they', 'them', 'then', 'than', 'how', 'why', 'can', 'all', 'through',
])
function parseEpisodeNumber(filePath) {
const match = path.basename(filePath).match(/Episode(\d+)/i)
return match ? Number(match[1]) : null
}
function getVariantRank(filePath) {
const name = path.basename(filePath).toLowerCase()
let score = 0
if (name.includes('expanded')) score += 30
if (name.includes('updated')) score += 20
if (!name.includes('expanded') && !name.includes('updated')) score += 10
if (filePath.includes(`${path.sep}Done${path.sep}Old${path.sep}`)) score -= 25
return score
}
async function collectDocxFiles(dir) {
const out = []
const items = await fs.readdir(dir, { withFileTypes: true })
for (const item of items) {
const fullPath = path.join(dir, item.name)
if (item.isDirectory()) {
out.push(...await collectDocxFiles(fullPath))
continue
}
if (!item.isFile()) continue
if (!item.name.toLowerCase().endsWith('.docx')) continue
if (item.name.startsWith('~$')) continue
out.push(fullPath)
}
return out
}
function pickBestPerEpisode(docxFiles) {
const byEpisode = new Map()
for (const filePath of docxFiles) {
const episode = parseEpisodeNumber(filePath)
if (!episode) continue
const current = byEpisode.get(episode)
const next = {
filePath,
episode,
rank: getVariantRank(filePath),
}
if (!current || next.rank > current.rank) {
byEpisode.set(episode, next)
}
}
return [...byEpisode.values()].sort((a, b) => a.episode - b.episode)
}
function extractDocText(filePath) {
const output = execFileSync('textutil', ['-convert', 'txt', '-stdout', filePath], { encoding: 'utf8' })
return output
}
function normalizeContent(text) {
const lines = text
.split(/\r?\n/)
.map(line => line.replace(/\s+/g, ' ').trim())
.filter(Boolean)
const filtered = lines.filter(line => {
const upper = line.toUpperCase()
if (upper === 'VERSE BY VERSE WITH NATE') return false
if (upper === 'A JOURNEY THROUGH SCRIPTURE') return false
return true
})
return filtered.join(' ').replace(/\s{2,}/g, ' ').trim()
}
function buildKeywords(title, content, existingKeywords = []) {
const tokens = `${title} ${content.slice(0, 1600)}`
.toLowerCase()
.replace(/[^a-z0-9\s:-]/g, ' ')
.split(/\s+/)
.filter(token => token.length >= 3 && !STOP_WORDS.has(token))
const counts = new Map()
for (const token of tokens) {
counts.set(token, (counts.get(token) ?? 0) + 1)
}
const top = [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 20)
.map(([token]) => token)
return [...new Set([...(existingKeywords ?? []), ...top])].slice(0, 25)
}
function getEpisodeFromTitle(title = '') {
const match = title.match(/Episode\s+(\d+)/i)
return match ? Number(match[1]) : null
}
function getEntryTitleFallback(episodeNumber, rawText, existingTitle) {
if (existingTitle && existingTitle.trim()) return existingTitle
const lineMatch = rawText.match(new RegExp(`EPISODE\\s+${episodeNumber}\\s*[—-]\\s*([^\\n]+)`, 'i'))
if (lineMatch) {
return `Episode ${episodeNumber} — ${lineMatch[1].trim()}`
}
return `Episode ${episodeNumber}`
}
async function run() {
const raw = await fs.readFile(CHATBOT_FILE, 'utf8')
const entries = JSON.parse(raw)
const docxFiles = await collectDocxFiles(DOCS_DIR)
const selected = pickBestPerEpisode(docxFiles)
const existingByEpisode = new Map()
for (const entry of entries) {
const episode = getEpisodeFromTitle(entry.title)
if (episode) existingByEpisode.set(episode, entry)
}
const now = new Date().toISOString()
let updated = 0
let added = 0
for (const item of selected) {
const rawText = extractDocText(item.filePath)
const content = normalizeContent(rawText)
if (!content) continue
const existing = existingByEpisode.get(item.episode)
if (existing) {
existing.type = 'episode'
existing.title = getEntryTitleFallback(item.episode, rawText, existing.title)
existing.content = content
existing.keywords = buildKeywords(existing.title, content, existing.keywords)
existing.updatedAt = now
updated += 1
continue
}
entries.push({
id: randomUUID(),
type: 'episode',
title: getEntryTitleFallback(item.episode, rawText, ''),
content,
keywords: buildKeywords(`Episode ${item.episode}`, content, []),
createdAt: now,
updatedAt: now,
})
added += 1
}
entries.sort((a, b) => {
const aEp = getEpisodeFromTitle(a.title)
const bEp = getEpisodeFromTitle(b.title)
if (aEp && bEp) return aEp - bEp
if (aEp && !bEp) return 1
if (!aEp && bEp) return -1
return 0
})
await fs.writeFile(CHATBOT_FILE, `${JSON.stringify(entries, null, 2)}\n`)
console.log(`Episodes selected from docs: ${selected.length}`)
console.log(`Updated entries: ${updated}`)
console.log(`Added entries: ${added}`)
for (const item of selected) {
console.log(`- Episode ${item.episode}: ${path.relative(ROOT, item.filePath)}`)
}
}
run().catch(error => {
console.error(error)
process.exitCode = 1
})
-71
View File
@@ -1,71 +0,0 @@
The Gospel, in a Courtroom.
Imagine a courtroom. Not a courtroom on earth, but the highest court there is: the Court of Heaven. God is the Judge. Satan is the accuser. The charges are your sins, and you are the one on trial. But before the verdict is read, a Substitute has stepped forward to take your place. Let me walk you through the case.
The Judge.
God is perfectly just. He will not overlook wrongdoing. Nothing is hidden from him, and he judges rightly. In the book of Genesis, chapter eighteen, verse twenty-five, Scripture asks: "Will not the Judge of all the earth do right?" And in Acts, chapter seventeen, verse thirty-one: "For He has set a day when He will judge the world with justice by the Man He has appointed."
The Accuser.
Every courtroom has an accuser, and ours is Satan. His very name means "adversary." Scripture calls him the accuser of the brothers, who brings charges against us before God. He does not have to invent anything. He only has to point to what we have actually done. Revelation, chapter twelve, verse ten, calls him "the accuser of our brothers, who accuses them before our God day and night." And First Peter, chapter five, verse eight, warns: "Your adversary the devil prowls around like a roaring lion, looking for someone to devour."
The Charges.
So what are the charges? The charges are the sins you have committed.
Sin is breaking God's law. That includes the wrong things we knowingly do, the good things we fail to do, and the things we say and think in secret.
It also includes breaking his law without knowing it. Not knowing the law has never made anyone innocent. God's standard is written on every conscience, so that no one is without excuse. Even sin done unknowingly still leaves a person guilty.
And God does not grade on a curve. Stumbling at one point means you have broken the whole law. We have all sinned. That includes you, and it includes me.
First John, chapter three, verse four: "Everyone who practices sin practices lawlessness; indeed, sin is lawlessness." James, chapter two, verse ten: "For whoever keeps the whole law but stumbles at just one point is guilty of breaking all of it." Romans, chapter three, verse ten: "There is no one righteous, not even one." And Romans, chapter three, verse twenty-three: "For all have sinned and fall short of the glory of God."
The Verdict.
The evidence is clear, and there is no defense. We cannot argue our way out, and our good deeds cannot cancel our guilt. The sentence for sin is death: separation from the God who made us. Romans, chapter three, verse nineteen: "...so that every mouth may be silenced and the whole world held accountable to God." And Romans, chapter six, verse twenty-three: "For the wages of sin is death..."
But that is not the end of the story.
The Substitute.
Then God himself stepped forward to take your place.
Jesus is God. The eternal Son took on flesh and was born as a baby in Bethlehem, and he came for one purpose: to take the place of sinners like you and me. He did not come to be served. He came to give his life, and the cross was always where the road was heading.
He lived the perfect life you could not live. On the cross he took the sentence that belonged to you, and he rose again on the third day to prove the payment was accepted.
John, chapter one, verse fourteen: "The Word became flesh and dwelt among us." Matthew, chapter one, verse twenty-one: "She will bear a Son, and you are to name Him Jesus, because He will save His people from their sins." Mark, chapter ten, verse forty-five: "For even the Son of Man did not come to be served, but to serve, and to give His life as a ransom for many." First Peter, chapter three, verse eighteen: "For Christ also suffered once for sins, the righteous for the unrighteous, to bring you to God." Second Corinthians, chapter five, verse twenty-one: "God made Him who knew no sin to be sin for us, so that in Him we might become the righteousness of God." And Romans, chapter five, verse eight: "But God proves His love for us in this: While we were still sinners, Christ died for us."
The Pardon.
The pardon is a gift. It cannot be earned or bought. You receive it by turning from your sin and trusting Jesus alone. When you do, God declares you forgiven and counts you righteous because of what Christ has done.
Ephesians, chapter two, verses eight and nine: "For by grace you have been saved through faith, and this is not from yourselves, but it is the gift of God, not by works, so that no one can boast." And Romans, chapter ten, verse nine: "If you confess with your mouth that Jesus is Lord, and believe in your heart that God raised Him from the dead, you will be saved."
The Result.
Case closed. The accuser has nothing left to say. Our sins were real charges, but they have been answered, because the penalty has been paid in full by the Substitute. The one who trusts in Christ is not waiting nervously for a verdict. The verdict has already been given in their favor.
Romans, chapter eight, verse one: "Therefore, there is now no condemnation for those who are in Christ Jesus." And verses thirty-three and thirty-four: "Who can bring an accusation against those whom God has chosen? It is God who justifies. Who then can condemn? Christ Jesus, who died, more than that, was raised to life, is at the right hand of God, and He is interceding for us."
Before you plead.
Before you plead, read what Jesus did for you. Everything you have just heard tells you that the Substitute took your place. The record shows you how: the night he was arrested, the trials where he was declared innocent and condemned anyway, the long road to the cross, and the empty tomb on the third day.
Please do not skip it. It is the story of what Jesus did for you, told by four witnesses and woven into one account. Take your time and read it slowly. You can pause this audio at any time, read the record, and then come back and press play. When you are ready, continue.
How do you plead?
The court is not asking whether you are a good person. It is asking you to be honest about the charges, and to decide where you will stand: on your own record, or on what Jesus has done in your place.
You have two choices.
You can plead guilty. That means: I admit it. I have broken God's law, whether I knew it or not, and I cannot save myself. I am not asking the court to find me innocent. I am asking for the pardon Jesus paid for with his life. And here is what happens: the Judge declares you forgiven, and counts you righteous because of Christ. The accuser has nothing left to say.
Or you can plead not guilty. That means: I stand on my own record. I believe I have kept God's law well enough to be found innocent, and I do not need anyone to take my place. That plea has a cost, and I will explain it if you choose it.
Now look at the two buttons below the player. When you are ready, press the one that matches your plea, and I will walk you through what happens next.
Take a moment. Be honest with God, and honest with yourself.
-19
View File
@@ -1,19 +0,0 @@
You have pleaded guilty. That is the beginning of the good news.
You are not earning anything by saying this. You are receiving a gift. Romans, chapter six, verse twenty-three: "For the wages of sin is death, but the gift of God is eternal life in Christ Jesus our Lord." Here is what to do next.
First, tell God. Words do not save you. Trusting Jesus does. But you can say it to him in your own words, or you can pray this prayer. If you mean it, pray it out loud with me.
"Lord God, I plead guilty. I have sinned against you, and I cannot save myself. I believe Jesus Christ took my place on the cross, died for my sins, and rose again. I turn from my sin, and I trust him alone. Thank you for the pardon I could never earn. Make me new, and help me follow you. Amen."
Second, believe it is finished. If you meant that prayer, God has forgiven you. Romans, chapter eight, verse one: "Therefore, there is now no condemnation for those who are in Christ Jesus." Your feelings may go up and down, but the verdict does not change.
Third, tell someone. Tell a Christian friend or family member what you decided.
Fourth, start reading. Begin with the Gospel of John, then keep going through the New Testament, and talk to God every day.
Fifth, find a church. Join a Bible-teaching church where you can grow, and be baptized as a public sign of your faith. Acts, chapter two, verse thirty-eight.
And if you prayed that prayer and meant it, we would love to rejoice with you. Press the button below that says, Let us know. You can leave your name and your email, and someone from our team will reach out to you personally. It is completely optional.
Welcome to the family of God.
@@ -1,23 +0,0 @@
You have pleaded not guilty. Please be sure you understand what that means.
Pleading not guilty is not just saying, "I am not a bad person." It is telling the Judge, "I have kept your law perfectly, and I need no one to take my place."
God's standard is not "better than most people." It is perfection. Matthew, chapter five, verse forty-eight: "Be perfect, therefore, as your heavenly Father is perfect." And breaking that standard at even one point makes a person guilty of breaking all of it. James, chapter two, verse ten: "For whoever keeps the whole law but stumbles at just one point is guilty of breaking all of it." That includes the things done without knowing they were wrong.
So test your plea honestly.
Have you ever told a lie, even a small one?
Have you ever taken something that was not yours?
Have you ever used God's name carelessly, or put something else ahead of him: money, a person, yourself?
Have you ever hated someone in your heart, or looked at someone with lust? Jesus said those count too. Matthew, chapter five.
If the answer to any of those is yes, then by God's own standard, the plea does not hold. First John, chapter one, verse eight: "If we say that we have no sin, we are deceiving ourselves, and the truth is not in us."
And here is what a not guilty plea costs. It means standing before the Judge on your own record, with no Substitute. The sentence for sin stands. Romans, chapter six, verse twenty-three. But that is not what God wants for you. He says he takes no pleasure in anyone's death. Ezekiel, chapter thirty-three, verse eleven. And the offer of pardon is open right now. Second Corinthians, chapter six, verse two: "Now is the day of salvation."
If you are not sure, that is okay. Questions and doubts are fine. Asking honestly is not the same as refusing.
If you would like to change your plea, press the button below that says, Change my plea to guilty. You can also read the record of what Jesus did for you, or ask us a question. Whatever you decide, thank you for listening honestly. The offer of the pardon is still open.
+1484 -304
View File
File diff suppressed because it is too large. Load diff
-17
View File
@@ -1,17 +0,0 @@
import { writeFile as fsWriteFile, rename, unlink } from 'node:fs/promises'
import { randomBytes } from 'node:crypto'
// Drop-in replacement for fs.promises.writeFile that never leaves a truncated
// file behind: the data goes to a sibling temp file which is then renamed over
// the target (atomic on the same filesystem). A crash mid-write leaves the
// previous version intact instead of a half-written JSON file.
export async function writeFile(file, data, options) {
const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
try {
await fsWriteFile(tmp, data, options)
await rename(tmp, file)
} catch (err) {
await unlink(tmp).catch(() => {})
throw err
}
}
-307
View File
@@ -1,307 +0,0 @@
import { createHash, randomUUID, timingSafeEqual, createHmac, randomFillSync } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { writeFile } from './atomic-write.js'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { parseCookies, cookieSecureFlag } from './helpers.js'
import { DATA_DIR } from './paths.js'
const TOTP_SECRET_FILE = path.join(DATA_DIR, 'totp-secret.json')
// Pending sessions: password verified, waiting for TOTP code
// Map<pendingToken, { expiresAt }>
const TOTP_PENDING_TTL_MS = 5 * 60 * 1000
const totpPendingSessions = new Map()
export const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
const ADMIN_SESSION_ABSOLUTE_TTL_MS = 30 * 24 * 60 * 60 * 1000
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
const adminSessions = new Map()
// Sessions survive restarts/deploys. Only a SHA-256 of each token is written to
// disk, so the file alone cannot be used to hijack a session. Sliding-expiry
// refreshes are not persisted; a restart reverts to the last persisted expiry.
const ADMIN_SESSIONS_FILE = path.join(DATA_DIR, 'admin-sessions.json')
let adminSessionsWrite = Promise.resolve()
function persistAdminSessions() {
const sessions = []
for (const [tokenHash, session] of adminSessions) {
if (session && typeof session === 'object') sessions.push({ tokenHash, ...session })
}
adminSessionsWrite = adminSessionsWrite
.then(() => writeFile(ADMIN_SESSIONS_FILE, JSON.stringify({ sessions }), { encoding: 'utf8', mode: 0o600 }))
.catch(err => { console.error('[admin-sessions] write failed:', err) })
}
// Loaded sessions are keyed by token hash; lookups hash the cookie value, so the
// in-memory map is keyed by hash throughout.
function adminSessionKey(token) {
return sha256(token)
}
try {
const parsed = JSON.parse(readFileSync(ADMIN_SESSIONS_FILE, 'utf8'))
const now = Date.now()
for (const e of Array.isArray(parsed?.sessions) ? parsed.sessions : []) {
if (typeof e?.tokenHash !== 'string' || typeof e.expiresAt !== 'number' || typeof e.absoluteExpiresAt !== 'number') continue
if (e.expiresAt <= now || e.absoluteExpiresAt <= now) continue
adminSessions.set(e.tokenHash, { expiresAt: e.expiresAt, absoluteExpiresAt: e.absoluteExpiresAt })
}
} catch { /* no persisted sessions */ }
export function sha256(value) {
return createHash('sha256').update(String(value)).digest('hex')
}
export function isAdminPasswordConfigured() {
return Boolean(ADMIN_PASSWORD)
}
export function validateAdminPasswordSetup() {
if (!isAdminPasswordConfigured() && process.env.NODE_ENV === 'production') {
throw new Error('ADMIN_PASSWORD is required in production.')
}
if (!isAdminPasswordConfigured()) {
console.warn('ADMIN_PASSWORD is not configured; admin routes will remain disabled until the environment is configured.')
}
}
export function isAdminPasswordValid(password) {
if (!isAdminPasswordConfigured()) return false
const a = Buffer.from(sha256(password), 'utf8')
const b = Buffer.from(sha256(ADMIN_PASSWORD), 'utf8')
if (a.length !== b.length) return false
return timingSafeEqual(a, b)
}
// ── TOTP (RFC 6238) — implemented with Node built-in crypto ─────────────────
const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
function base32Decode(str) {
const s = str.toUpperCase().replace(/=+$/, '')
let bits = 0
let value = 0
const output = []
for (const char of s) {
const idx = BASE32_CHARS.indexOf(char)
if (idx === -1) continue
value = (value << 5) | idx
bits += 5
if (bits >= 8) {
output.push((value >>> (bits - 8)) & 0xff)
bits -= 8
}
}
return Buffer.from(output)
}
function base32Encode(buf) {
let bits = 0
let value = 0
let output = ''
for (const byte of buf) {
value = (value << 8) | byte
bits += 8
while (bits >= 5) {
output += BASE32_CHARS[(value >>> (bits - 5)) & 0x1f]
bits -= 5
}
}
if (bits > 0) output += BASE32_CHARS[(value << (5 - bits)) & 0x1f]
return output
}
function totpToken(secret, counter) {
const key = base32Decode(secret)
const msg = Buffer.alloc(8)
// Write 64-bit big-endian counter
const hi = Math.floor(counter / 0x100000000)
const lo = counter >>> 0
msg.writeUInt32BE(hi, 0)
msg.writeUInt32BE(lo, 4)
const hmac = createHmac('sha1', key).update(msg).digest()
const offset = hmac[hmac.length - 1] & 0x0f
const code = ((hmac[offset] & 0x7f) << 24)
| (hmac[offset + 1] << 16)
| (hmac[offset + 2] << 8)
| hmac[offset + 3]
return String(code % 1000000).padStart(6, '0')
}
export function generateTotpSecret() {
const buf = Buffer.allocUnsafe(20)
randomFillSync(buf)
return base32Encode(buf)
}
export async function loadTotpState() {
try {
const raw = await readFile(TOTP_SECRET_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return null
}
}
export async function saveTotpState(state) {
await writeFile(TOTP_SECRET_FILE, JSON.stringify(state, null, 2), 'utf8')
}
export async function isTotpEnabled() {
const state = await loadTotpState()
return Boolean(state?.secret && state?.verified)
}
function randomBytesForRecovery(n) {
const buf = Buffer.allocUnsafe(n)
randomFillSync(buf)
return buf
}
export function getTotpUri(secret, label = 'Siteforge Admin') {
const issuer = 'Siteforge'
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(label)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`
}
export function verifyTotpCode(secret, code) {
try {
const token = String(code).replace(/\s/g, '')
const step = Math.floor(Date.now() / 1000 / 30)
// Accept current step and one step either side (±30 seconds clock skew)
for (const offset of [-1, 0, 1]) {
if (totpToken(secret, step + offset) === token) return true
}
return false
} catch {
return false
}
}
// ── Recovery Codes ──────────────────────────────────────────────────────────
const RECOVERY_CODE_COUNT = 8
function generateRecoveryCode() {
// Format: XXXX-XXXX-XXXX (uppercase alphanumeric, no ambiguous chars)
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const randBytes = randomBytesForRecovery(12)
let code = ''
for (let i = 0; i < 12; i++) {
if (i > 0 && i % 4 === 0) code += '-'
code += chars[randBytes[i] % chars.length]
}
return code
}
export function generateRecoveryCodes() {
const codes = []
for (let i = 0; i < RECOVERY_CODE_COUNT; i++) {
codes.push(generateRecoveryCode())
}
return codes
}
export function hashRecoveryCode(code) {
return sha256(code.replace(/-/g, '').toUpperCase())
}
// Returns the matched code if valid, null otherwise. Mutates state.hashedRecoveryCodes.
export function consumeRecoveryCode(state, inputCode) {
if (!Array.isArray(state.hashedRecoveryCodes) || state.hashedRecoveryCodes.length === 0) return false
const normalized = inputCode.replace(/[-\s]/g, '').toUpperCase()
const inputHash = sha256(normalized)
const idx = state.hashedRecoveryCodes.findIndex(h => {
const a = Buffer.from(h, 'utf8')
const b = Buffer.from(inputHash, 'utf8')
return a.length === b.length && timingSafeEqual(a, b)
})
if (idx === -1) return false
state.hashedRecoveryCodes.splice(idx, 1)
return true
}
// ── Pending (password-ok, awaiting TOTP) sessions ───────────────────────────
export function createPendingSession() {
const token = randomUUID()
totpPendingSessions.set(token, { expiresAt: Date.now() + TOTP_PENDING_TTL_MS })
return token
}
export function consumePendingSession(token) {
if (!token) return false
const entry = totpPendingSessions.get(token)
if (!entry || entry.expiresAt <= Date.now()) {
totpPendingSessions.delete(token)
return false
}
totpPendingSessions.delete(token)
return true
}
// ── Admin Sessions ───────────────────────────────────────────────────────────
export function createAdminSession() {
const token = randomUUID()
const now = Date.now()
adminSessions.set(adminSessionKey(token), { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt: now + ADMIN_SESSION_ABSOLUTE_TTL_MS })
persistAdminSessions()
return token
}
export function deleteAdminSession(token) {
if (token) {
adminSessions.delete(adminSessionKey(token))
persistAdminSessions()
}
}
export function isValidAdminSession(req) {
if (!isAdminPasswordConfigured()) return false
const cookies = parseCookies(req.headers.cookie)
const sessionToken = cookies[ADMIN_SESSION_COOKIE]
if (!sessionToken) return false
const now = Date.now()
const key = adminSessionKey(sessionToken)
const session = adminSessions.get(key)
if (!session) return false
// Support legacy sessions stored as a plain number (expiresAt)
const expiresAt = typeof session === 'object' ? session.expiresAt : session
const absoluteExpiresAt = typeof session === 'object' ? session.absoluteExpiresAt : Infinity
if (expiresAt <= now || absoluteExpiresAt <= now) {
adminSessions.delete(key)
return false
}
adminSessions.set(key, { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt })
return true
}
export function setAdminSessionCookie(res, token) {
res.append(
'Set-Cookie',
`${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
)
}
export function clearAdminSessionCookie(res) {
res.append(
'Set-Cookie',
`${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
)
}
export function requireAdminAuth(req, res, next) {
if (!isValidAdminSession(req)) {
res.status(401).json({ message: 'Unauthorized' })
return
}
next()
}
-193
View File
@@ -1,193 +0,0 @@
import path from 'node:path'
import { readFileSync } from 'node:fs'
import { execSync } from 'node:child_process'
import { validateAdminPasswordSetup } from './auth.js'
import { ROOT_DIR, DATA_DIR } from './paths.js'
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() {
try {
const pkg = JSON.parse(readFileSync(path.join(ROOT_DIR, 'package.json'), 'utf8'))
return typeof pkg.version === 'string' && pkg.version.trim() ? pkg.version.trim() : 'unknown'
} catch {
return 'unknown'
}
}
function readGitCommit() {
// CI injects the full SHA as COMMIT_SHA; fall back to running git locally.
if (typeof process.env.COMMIT_SHA === 'string' && process.env.COMMIT_SHA.trim()) {
return process.env.COMMIT_SHA.trim().slice(0, 7)
}
try {
return execSync('git rev-parse --short HEAD', { cwd: ROOT_DIR, timeout: 2000 }).toString().trim()
} catch {
return 'unknown'
}
}
// APP_VERSION is sourced from package.json.
// GIT_COMMIT is the 7-char short SHA, from COMMIT_SHA env var (set by CI) or git at startup.
export const APP_VERSION = readPackageVersion()
export const GIT_COMMIT = readGitCommit()
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 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 NUGGETS_UPLOAD_HOSTS_FILE = path.join(DATA_DIR, 'nuggets-upload-hosts.json')
export const NUGGETS_SEAFILE_SEEN_FILE = path.join(DATA_DIR, 'nuggets-seafile-seen.json')
export const NUGGETS_UPLOAD_HISTORY_FILE = path.join(DATA_DIR, 'nuggets-upload-history.json')
export const NUGGETS_PORTAL_ANNOUNCEMENT_FILE = path.join(DATA_DIR, 'nuggets-portal-announcement.json')
export const NUGGETS_PORTAL_GUIDELINES_FILE = path.join(DATA_DIR, 'nuggets-portal-guidelines.json')
export const SEAFILE_NUGGETS_SHARE_URL = process.env.SEAFILE_NUGGETS_SHARE_URL ?? 'https://seafile.necloud.us/d/437ee6b8476d4b7e90a2/'
// Email address for Nuggets upload notifications (defaults to the admin inbox)
export const NUGGETS_NOTIFY_EMAIL = process.env.NUGGETS_NOTIFY_EMAIL ?? process.env.RESEND_TO ?? 'hello@versebyversewithnate.us'
export const SEAFILE_POLL_INTERVAL_MS = 15 * 60 * 1000 // 15 minutes
export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json')
export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json')
export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json')
export const STUDY_SESSIONS_FILE = path.join(DATA_DIR, 'study-sessions.json')
export const NEWSLETTER_ISSUES_FILE = path.join(DATA_DIR, 'newsletter-issues.json')
export const NUGGETS_TAGS_FILE = path.join(DATA_DIR, 'nuggets-tags.json')
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
// Seafile integration for Nuggets episode uploads
export const SEAFILE_URL = (process.env.SEAFILE_URL ?? '').replace(/\/$/, '')
export const SEAFILE_TOKEN = process.env.SEAFILE_TOKEN ?? ''
export const SEAFILE_NUGGETS_REPO_ID = process.env.SEAFILE_NUGGETS_REPO_ID ?? ''
export const SEAFILE_EPISODE_INFO_DIR = process.env.SEAFILE_EPISODE_INFO_DIR ?? '/Episode Info'
export const SEAFILE_EPISODE_UPLOAD_DIR = process.env.SEAFILE_EPISODE_UPLOAD_DIR ?? '/Episode Upload'
// Cloudflare R2 storage
export const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID ?? ''
export const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID ?? ''
export const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY ?? ''
export const R2_BUCKET = process.env.R2_BUCKET ?? ''
export const R2_PUBLIC_URL = (process.env.R2_PUBLIC_URL ?? '').replace(/\/$/, '')
export const HOST_SESSION_COOKIE = 'vbn_host_session'
export const HOST_SESSION_TTL_MS = 24 * 60 * 60 * 1000
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 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 = 3000
export const MAX_STUDY_NOTE_LENGTH = 200000
export const EMAIL_CHANGE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
export const EMAIL_VERIFY_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000
export const PASSWORD_RESET_TOKEN_TTL_MS = 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_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 EMPTY_HIT_STATS = {
totalHits: 0,
realHits: 0,
botHits: 0,
firstHitAt: null,
lastHitAt: null,
byPath: {},
byPathReal: {},
byPathBot: {},
byPathUnknown: {},
byDay: {},
byDayReal: {},
byDayBot: {},
botReasons: {},
}
export const EMPTY_VISITOR_STATS = {
totalVisits: 0,
uniqueVisitors: 0,
returningVisits: 0,
firstVisitAt: null,
lastVisitAt: null,
visitors: {},
ipHashIndex: {},
recentVisits: [],
geoCacheByIp: {},
}
-1618
View File
File diff suppressed because it is too large. Load diff
-962
View File
@@ -1,962 +0,0 @@
import { Resend } from 'resend'
import { escapeHtml, buildAbsoluteUrl, splitName } from './helpers.js'
import {
DEFAULT_RESEND_FROM,
DEFAULT_RESEND_TO,
DEFAULT_RESEND_REPLY_TO,
DEFAULT_SEO,
} from './config.js'
import { state } from './state.js'
// ── Address helpers ────────────────────────────────────────────────────────
export function getResendFromAddress() {
return process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
}
export function getResendReplyToAddress() {
return process.env.RESEND_REPLY_TO ?? DEFAULT_RESEND_REPLY_TO
}
export function getResendInboxAddress() {
return process.env.RESEND_TO ?? DEFAULT_RESEND_TO
}
export function getCanonicalBaseUrl() {
const configured = state.cachedSiteContent?.seo?.canonicalUrl ?? DEFAULT_SEO.canonicalUrl
if (typeof configured !== 'string' || !configured.trim()) return DEFAULT_SEO.canonicalUrl
return configured.trim()
}
export function getAddressDomain(addressValue) {
const raw = String(addressValue || '').trim()
if (!raw) return ''
const candidate = raw.includes('<') && raw.includes('>')
? raw.slice(raw.lastIndexOf('<') + 1, raw.lastIndexOf('>')).trim()
: raw
const at = candidate.lastIndexOf('@')
if (at <= 0 || at === candidate.length - 1) return ''
return candidate.slice(at + 1).toLowerCase()
}
export function logResendEmailAlignmentWarnings() {
const warnings = []
const fromAddress = getResendFromAddress()
const replyToAddress = getResendReplyToAddress()
const fromDomain = getAddressDomain(fromAddress)
const replyDomain = getAddressDomain(replyToAddress)
const hasApiKey = Boolean(process.env.RESEND_API_KEY)
if (!hasApiKey) warnings.push('RESEND_API_KEY is missing. Contact and reply emails cannot send.')
if (!fromDomain) warnings.push('RESEND_FROM is missing or malformed. Use a verified domain sender identity.')
if (fromDomain.endsWith('resend.dev')) warnings.push('RESEND_FROM uses resend.dev. Move to your own verified domain for best deliverability.')
if (fromDomain && replyDomain && fromDomain !== replyDomain) warnings.push('RESEND_FROM and RESEND_REPLY_TO use different domains. This can weaken alignment.')
if (!process.env.RESEND_WEBHOOK_TOKEN) warnings.push('RESEND_WEBHOOK_TOKEN is not set. Delivery webhooks are not authenticated.')
if (hasApiKey) warnings.push('Verify SPF, DKIM, and DMARC for the sender domain to improve inbox placement.')
if (warnings.length > 0) {
console.warn('[email-health] Resend alignment checks:')
for (const warning of warnings) {
console.warn(`[email-health] - ${warning}`)
}
}
}
// ── Send helpers ───────────────────────────────────────────────────────────
export async function sendResendEmailWithRetry({ resend, payload, context, maxAttempts = 2 }) {
let lastError = null
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const result = await resend.emails.send(payload)
if (!result?.error) return result
lastError = result.error
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 250 * attempt))
}
} catch (err) {
lastError = err
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 250 * attempt))
}
}
}
throw lastError ?? new Error(`[${context}] email send failed`)
}
// Retry a Resend SDK call when the account's rate limit (429) is hit. The SDK returns { error } rather than throwing.
export async function resendRetry(call, maxAttempts = 5) {
let result
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
result = await call()
const err = result?.error
const limited = err && (err.statusCode === 429 || err.name === 'rate_limit_exceeded')
if (!limited) return result
await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt))
}
return result
}
export async function syncContactToResend(name, email) {
if (!process.env.RESEND_API_KEY) return
const { firstName, lastName } = splitName(name)
const contactResend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
try {
// Never touch `unsubscribed` for a contact that already exists — otherwise a form submission
// would silently resubscribe someone who opted out in Resend.
const { data: existing } = await resendRetry(() => contactResend.contacts.get({ email }))
if (existing) {
const { error: updateError } = await resendRetry(() => contactResend.contacts.update({ email, firstName, lastName }))
if (updateError) console.error('[resend] contact sync error:', updateError)
return
}
const { error: createError } = await resendRetry(() => contactResend.contacts.create({
email,
firstName,
lastName,
unsubscribed: false,
...(process.env.RESEND_SEGMENT_ID ? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] } : {}),
}))
if (createError) console.error('[resend] contact sync error:', createError)
} catch (err) {
console.error('[resend] contact sync exception:', err)
}
}
// ── Email HTML builders ────────────────────────────────────────────────────
export function buildBrandedEmailHtml({ title, eyebrow, bodyHtml, ctaLabel, ctaUrl, footerHtml }) {
const ctaBlock = ctaLabel && ctaUrl
? `<p style="margin:24px 0 0;"><a href="${escapeHtml(ctaUrl)}" target="_blank" style="display:inline-block;padding:12px 22px;background:#c9a84c;border:1px solid #e0c070;border-radius:999px;color:#111111;font-family:Georgia,serif;font-size:13px;font-weight:700;letter-spacing:0.14em;text-decoration:none;text-transform:uppercase;">${escapeHtml(ctaLabel)}</a></p>`
: ''
return (
`<div style="margin:0;padding:0;background-color:#0a0a08;font-family:Georgia,serif;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#0a0a08;">` +
`<tr><td align="center" style="padding:40px 20px;">` +
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:580px;margin:0 auto;background-color:#0f0f0c;border:1px solid #2a2518;">` +
`<tr><td align="center" style="background-color:#0d0d0a;padding:36px 40px 28px;border-bottom:1px solid #2a2518;">` +
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">${escapeHtml(eyebrow ?? 'Verse by Verse with Nate')}</p>` +
`<p style="margin:0;font-family:Georgia,serif;font-size:28px;font-weight:600;color:#e0c070;line-height:1.2;">${escapeHtml(title)}</p>` +
`</td></tr>` +
`<tr><td style="padding:40px 40px 0;">` +
`<div style="font-family:Georgia,serif;font-size:15px;line-height:1.8;color:#c8c0ac;">${bodyHtml}</div>` +
`${ctaBlock}` +
`</td></tr>` +
`<tr><td style="padding:28px 40px 40px;text-align:center;">${footerHtml ?? ''}</td></tr>` +
`</table>` +
`</td></tr></table>` +
`</div>`
)
}
export function buildContactWelcomeEmailTemplate({
greetingName,
welcomeIntro,
welcomeCurrentSeries,
welcomeStartHereTitle,
welcomeStartHereSummary,
welcomeExpect1,
welcomeExpect2,
welcomeExpect3,
welcomeScripture,
welcomeScriptureRef,
welcomeSignoff,
welcomeGreetingPrefix = "Glad you're here",
welcomeSpotifyUrl,
welcomeAppleUrl,
welcomeAmazonUrl,
welcomeWebsiteUrl,
welcomeEpisodeUrl,
welcomeImageUrl,
welcomeSpotifyBtnLabel = 'Listen on Spotify',
welcomeAppleBtnLabel = 'Apple Podcasts',
welcomeStartHereLinkLabel = 'Open Start Here page',
}) {
const heading = greetingName
? `${escapeHtml(welcomeGreetingPrefix)}, ${escapeHtml(greetingName)}.`
: `${escapeHtml(welcomeGreetingPrefix)}.`
return {
text:
`Welcome to Verse by Verse with Nate!\n\n` +
`${heading}\n\n` +
`${welcomeIntro}\n\n` +
`${welcomeCurrentSeries}\n\n` +
`Start here: ${welcomeEpisodeUrl}\n` +
`${welcomeStartHereTitle}\n` +
`${welcomeStartHereSummary}\n` +
`${welcomeSpotifyBtnLabel}: ${welcomeSpotifyUrl}\n` +
`${welcomeAppleBtnLabel}: ${welcomeAppleUrl}\n` +
`Amazon Music: ${welcomeAmazonUrl}\n` +
`Website: ${welcomeWebsiteUrl}\n\n` +
`What to expect:\n` +
`- ${welcomeExpect1}\n` +
`- ${welcomeExpect2}\n` +
`- ${welcomeExpect3}\n\n` +
`"${welcomeScripture}"\n${welcomeScriptureRef}\n\n` +
`${welcomeSignoff}`,
html: `<html dir="ltr" lang="en">
<head></head>
<body style="background-color:#ffffff">
<table border="0" width="100%" cellpadding="0" cellspacing="0" role="presentation" align="center">
<tbody>
<tr>
<td style="background-color:#ffffff">
<table align="left" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width:600px;align:left;width:100%;color:#000000;background-color:#ffffff;border-radius:0px;border-color:#000000">
<tbody>
<tr style="width:100%">
<td style="padding:0">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background-color:#0a0a08">
<tbody>
<tr>
<td align="center" style="padding:40px 20px">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto;max-width:580px;background-color:#0f0f0c;border:1px solid #2a2518">
<tbody>
<tr>
<td align="center" style="padding:36px 40px 28px;background-color:#0d0d0a;border-bottom:1px solid #2a2518">
<img alt="Verse by Verse with Nate" src="${escapeHtml(welcomeImageUrl)}" style="display:block;outline:none;border:2px solid #2a2518;text-decoration:none;max-width:100%;margin:0 auto 20px;border-radius:12px;height:auto" width="468" />
<p style="margin:0 0 6px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:22px;font-weight:600;color:#c9a84c;letter-spacing:0.04em">Verse by Verse with Nate</p>
<p style="margin:0;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:14px;font-weight:300;color:#7a7060;letter-spacing:0.08em;text-transform:uppercase">Verse by verse. Nugget by nugget.</p>
</td>
</tr>
<tr>
<td style="padding:40px 40px 0">
<p style="margin:0 0 8px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase">Welcome</p>
<h1 style="margin:0 0 20px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:30px;font-weight:600;color:#f0ead8;line-height:1.2">${heading}</h1>
<div style="width:40px;height:2px;background-color:#c9a84c;margin-bottom:28px"></div>
</td>
</tr>
<tr>
<td style="padding:0 40px 32px">
<p style="margin:0 0 18px;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75">${escapeHtml(welcomeIntro)}</p>
<p style="margin:0 0 18px;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75">${escapeHtml(welcomeCurrentSeries)}</p>
<p style="margin:0;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:18px;font-weight:300;color:#c8c0ac;line-height:1.75">If you&#8217;re just joining us, the best place to start is Episode 1. It sets the table for everything that follows.</p>
</td>
</tr>
<tr><td style="padding:0 40px"><div style="height:1px;background-color:#2a2518;margin-bottom:32px"></div></td></tr>
<tr>
<td style="padding:0 40px 32px">
<p style="margin:0 0 6px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase">Start here</p>
<p style="margin:0 0 6px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:22px;font-weight:600;color:#f0ead8">${escapeHtml(welcomeStartHereTitle)}</p>
<p style="margin:0 0 20px;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:16px;font-weight:300;color:#7a7060;line-height:1.6">${escapeHtml(welcomeStartHereSummary)}</p>
<table border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td style="padding-right:12px">
<a href="${escapeHtml(welcomeSpotifyUrl)}" rel="noopener noreferrer nofollow" style="color:#0d0d0a;text-decoration:none;display:inline-block;padding:11px 22px;background-color:#c9a84c;font-family:'Cormorant Garamond',Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;border-radius:3px" target="_blank">${escapeHtml(welcomeSpotifyBtnLabel)}</a>
</td>
<td>
<a href="${escapeHtml(welcomeAppleUrl)}" rel="noopener noreferrer nofollow" style="color:#c9a84c;text-decoration:none;display:inline-block;padding:11px 22px;background-color:transparent;font-family:'Cormorant Garamond',Georgia,serif;font-size:14px;font-weight:600;letter-spacing:0.06em;border-radius:3px;border:1px solid #c9a84c" target="_blank">${escapeHtml(welcomeAppleBtnLabel)}</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr><td style="padding:0 40px"><div style="height:1px;background-color:#2a2518;margin-bottom:32px"></div></td></tr>
<tr>
<td style="padding:0 40px 32px">
<p style="margin:0 0 20px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase">What to expect</p>
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin-bottom:18px">
<tbody><tr>
<td style="width:28px;vertical-align:top;padding-top:3px"><div style="width:6px;height:6px;background-color:#c9a84c;border-radius:50%;margin-top:7px"></div></td>
<td><p style="margin:0;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65">${escapeHtml(welcomeExpect1)}</p></td>
</tr></tbody>
</table>
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin-bottom:18px">
<tbody><tr>
<td style="width:28px;vertical-align:top;padding-top:3px"><div style="width:6px;height:6px;background-color:#c9a84c;border-radius:50%;margin-top:7px"></div></td>
<td><p style="margin:0;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65">${escapeHtml(welcomeExpect2)}</p></td>
</tr></tbody>
</table>
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody><tr>
<td style="width:28px;vertical-align:top;padding-top:3px"><div style="width:6px;height:6px;background-color:#c9a84c;border-radius:50%;margin-top:7px"></div></td>
<td><p style="margin:0;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:17px;font-weight:400;color:#c8c0ac;line-height:1.65">${escapeHtml(welcomeExpect3)}</p></td>
</tr></tbody>
</table>
</td>
</tr>
<tr><td style="padding:0 40px"><div style="height:1px;background-color:#2a2518;margin-bottom:32px"></div></td></tr>
<tr>
<td style="padding:0 40px 40px">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-left:2px solid #c9a84c">
<tbody><tr>
<td style="padding:4px 0 4px 20px">
<p style="margin:0 0 8px;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:19px;font-style:italic;font-weight:400;color:#e0c070;line-height:1.6">&#8220;${escapeHtml(welcomeScripture)}&#8221;</p>
<p style="margin:0;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em;text-transform:uppercase">${escapeHtml(welcomeScriptureRef)}</p>
</td>
</tr></tbody>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding:28px 40px;background-color:#0a0a08;border-top:1px solid #2a2518;text-align:center">
<p style="margin:0 0 14px;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:13px;font-weight:300;color:#7a7060;letter-spacing:0.06em">Find the podcast on</p>
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin:0 auto 24px">
<tbody><tr>
<td style="padding:0 10px"><a href="${escapeHtml(welcomeSpotifyUrl)}" rel="noopener noreferrer nofollow" style="color:#7a7060;text-decoration:none;font-family:'Crimson Pro',Georgia,serif;font-size:13px;font-weight:300;letter-spacing:0.04em" target="_blank">Spotify</a></td>
<td style="color:#7a7060;font-size:12px">&#183;</td>
<td style="padding:0 10px"><a href="${escapeHtml(welcomeAppleUrl)}" rel="noopener noreferrer nofollow" style="color:#7a7060;text-decoration:none;font-family:'Crimson Pro',Georgia,serif;font-size:13px;font-weight:300;letter-spacing:0.04em" target="_blank">Apple Podcasts</a></td>
<td style="color:#7a7060;font-size:12px">&#183;</td>
<td style="padding:0 10px"><a href="${escapeHtml(welcomeAmazonUrl)}" rel="noopener noreferrer nofollow" style="color:#7a7060;text-decoration:none;font-family:'Crimson Pro',Georgia,serif;font-size:13px;font-weight:300;letter-spacing:0.04em" target="_blank">Amazon Music</a></td>
<td style="color:#7a7060;font-size:12px">&#183;</td>
<td style="padding:0 10px"><a href="${escapeHtml(welcomeWebsiteUrl)}" rel="noopener noreferrer nofollow" style="color:#7a7060;text-decoration:none;font-family:'Crimson Pro',Georgia,serif;font-size:13px;font-weight:300;letter-spacing:0.04em" target="_blank">Website</a></td>
</tr></tbody>
</table>
<p style="margin:0 0 6px;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:12px;font-weight:300;color:#6b6044;line-height:1.6">You&#8217;re receiving this because you subscribed to <strong>Verse by Verse with Nate</strong>.</p>
<p style="margin:0 0 18px;padding:0;font-family:'Crimson Pro',Georgia,serif;font-size:12px;font-weight:300;color:#6b6044"><a href="{{{RESEND_UNSUBSCRIBE_URL}}}" rel="noopener noreferrer nofollow" style="color:#6b6044;text-decoration:underline" target="_blank">Unsubscribe</a></p>
<p style="margin:0;padding:0;font-family:'Cormorant Garamond',Georgia,serif;font-size:13px;font-style:italic;font-weight:400;color:#6b6044">&#8220;Your word is a lamp to my feet and a light to my path.&#8221; &#8212; Psalm 119:105</p>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>`,
}
}
export function buildContactAdminNotificationTemplate({
normalizedMessageType,
trimmedName,
trimmedEmail,
submittedAt,
trimmedMessage,
}) {
return {
subject: `Verse by Verse contact (${normalizedMessageType}): ${trimmedName}`,
text:
`New contact form submission\n\n` +
`Message Type: ${normalizedMessageType}\n` +
`Name: ${trimmedName}\n` +
`Email: ${trimmedEmail}\n` +
`Submitted: ${submittedAt}\n\n` +
`Message:\n${trimmedMessage}`,
html:
`<div style="background:#f5f1e8;padding:24px;font-family:Georgia,serif;color:#201a10;">` +
`<div style="max-width:680px;margin:0 auto;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">` +
`<div style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">` +
`<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>` +
`<h1 style="margin:10px 0 0;color:#f4ead5;font-size:28px;line-height:1.2;">New Contact Form Submission</h1>` +
`</div>` +
`<div style="padding:24px;">` +
`<p style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.6;color:#57452b;">A new message was sent from the website contact form. Reply directly to this email to respond to <strong>${escapeHtml(trimmedName)}</strong>.</p>` +
`<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:20px;">` +
`<tr><td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Type</td><td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(normalizedMessageType)}</td></tr>` +
`<tr><td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Name</td><td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(trimmedName)}</td></tr>` +
`<tr><td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Email</td><td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;"><a href="mailto:${escapeHtml(trimmedEmail)}" style="color:#8f5f05;text-decoration:none;">${escapeHtml(trimmedEmail)}</a></td></tr>` +
`<tr><td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;">Submitted</td><td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;">${escapeHtml(submittedAt)}</td></tr>` +
`</table>` +
`<div style="background:#fbf7ef;border:1px solid #efe4cc;border-radius:12px;padding:18px 20px;">` +
`<div style="margin:0 0 10px;font-family:Arial,sans-serif;font-size:13px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#8a6d35;">Message</div>` +
`<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;white-space:pre-wrap;">${escapeHtml(trimmedMessage)}</div>` +
`</div>` +
`</div>` +
`</div>` +
`</div>`,
}
}
export function buildAdminReplyTemplate({ recipientName, message, signature }) {
const safeRecipientName = escapeHtml(recipientName || 'friend')
const safeMessage = escapeHtml(message).replace(/\n/g, '<br/>')
const sig = typeof signature === 'string' && signature.trim() ? signature.trim() : 'Grace and peace,\nVerse by Verse with Nate'
const safeSig = escapeHtml(sig).replace(/\n/g, '<br/>')
return `
<div style="margin:0;padding:0;background-color:#f5f1e8;font-family:Georgia,serif;color:#201a10;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#f5f1e8;">
<tr>
<td align="center" style="padding:28px 16px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:680px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
<tr>
<td style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>
<h1 style="margin:10px 0 0;color:#f4ead5;font-size:26px;line-height:1.2;">A Personal Reply</h1>
</td>
</tr>
<tr>
<td style="padding:26px 24px 18px;">
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:16px;line-height:1.6;color:#201a10;">Hi ${safeRecipientName},</p>
<div style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeMessage}</div>
<p style="margin:0;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeSig}</p>
</td>
</tr>
<tr>
<td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:14px 24px;">
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.5;color:#735a2b;">From: hello@versebyversewithnate.us</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
`
}
export function buildQuestionAnsweredEmailTemplate({ firstName, question, answer, questionUrl }) {
const cfg = state.cachedSiteContent ?? {}
const subject = cfg.qaAnsweredEmailSubject?.trim() || 'Your question has been answered — Verse by Verse with Nate'
const bodyText = cfg.qaAnsweredEmailBody?.trim() || 'Nate has answered your question on Verse by Verse with Nate.'
const ctaLabel = cfg.qaAnsweredEmailCtaLabel?.trim() || 'Read Full Answer →'
const signoff = cfg.qaAnsweredEmailSignoff?.trim() || 'Grace and peace,\nNate'
const safeName = escapeHtml(firstName || 'friend')
const safeQuestion = escapeHtml(question)
const safeAnswer = escapeHtml(answer.length > 600 ? answer.slice(0, 597) + '…' : answer).replace(/\n/g, '<br/>')
const safeUrl = escapeHtml(questionUrl)
const safeBody = escapeHtml(bodyText)
const safeSignoff = escapeHtml(signoff).replace(/\n/g, '<br/>')
const text = `Hi ${firstName || 'friend'},\n\n${bodyText}\n\nYour question: ${question}\n\nAnswer: ${answer}\n\nRead it at: ${questionUrl}\n\n${signoff}`
const html = `
<div style="margin:0;padding:0;background-color:#f5f1e8;font-family:Georgia,serif;color:#201a10;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#f5f1e8;">
<tr>
<td align="center" style="padding:28px 16px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:680px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
<tr>
<td style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>
<h1 style="margin:10px 0 0;color:#f4ead5;font-size:26px;line-height:1.2;">Your Question Was Answered</h1>
</td>
</tr>
<tr>
<td style="padding:26px 24px 18px;">
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:16px;line-height:1.6;color:#201a10;">Hi ${safeName},</p>
<p style="margin:0 0 16px;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeBody}</p>
<div style="background:#f7f2e5;border-left:3px solid #c8860a;padding:14px 18px;margin:0 0 20px;border-radius:0 8px 8px 0;">
<p style="margin:0 0 6px;font-family:Arial,sans-serif;font-size:13px;letter-spacing:0.06em;text-transform:uppercase;color:#a07830;">Your question</p>
<p style="margin:0;font-family:Georgia,serif;font-size:15px;line-height:1.6;color:#201a10;font-style:italic;">${safeQuestion}</p>
</div>
<div style="margin:0 0 24px;">
<p style="margin:0 0 6px;font-family:Arial,sans-serif;font-size:13px;letter-spacing:0.06em;text-transform:uppercase;color:#a07830;">Answer</p>
<p style="margin:0;font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;">${safeAnswer}</p>
</div>
<a href="${safeUrl}" style="display:inline-block;background:#c8860a;color:#ffffff;font-family:Arial,sans-serif;font-size:14px;font-weight:bold;text-decoration:none;padding:12px 24px;border-radius:6px;">${escapeHtml(ctaLabel)}</a>
</td>
</tr>
<tr>
<td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:14px 24px;">
<p style="margin:0 0 12px;font-family:Arial,sans-serif;font-size:13px;line-height:1.6;color:#201a10;">${safeSignoff}</p>
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.5;color:#735a2b;">You received this because you requested a notification when this question was answered.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
`
return { subject, text, html }
}
// ── Transactional email senders ────────────────────────────────────────────
export async function sendStudyWelcomeEmail(email, displayName) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const cfg = state.cachedSiteContent ?? {}
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const baseUrl = getCanonicalBaseUrl()
const accountUrl = buildAbsoluteUrl(baseUrl, '/study/account')
const subject = cfg.studyWelcomeEmailSubject?.trim() || 'Welcome to the Study Community'
const bodyText = cfg.studyWelcomeEmailBody?.trim() || 'Your student account is ready. Open your studies and continue learning, or manage your account details anytime.'
const ctaLabel = cfg.studyWelcomeEmailCtaLabel?.trim() || 'Open Studies'
const studiesUrl = buildAbsoluteUrl(baseUrl, cfg.studyWelcomeEmailCtaPath?.trim() || '/study')
const signoff = cfg.studyWelcomeEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate'
const bodyHtml = (
`<p style="margin:0 0 16px;">Welcome, <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>.</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>`
)
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(signoff).replace(/\n/g, '<br/>')}</p>`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `Welcome, ${namePart}.\n\n${bodyText}\n\nOpen studies: ${studiesUrl}\nManage account: ${accountUrl}\n\n${signoff}`,
html: buildBrandedEmailHtml({
title: 'Welcome to the Study Community',
eyebrow: 'Study Account',
bodyHtml,
ctaLabel,
ctaUrl: studiesUrl,
footerHtml: footerHtml + `<p style="margin:14px 0 0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;"><a href="${escapeHtml(accountUrl)}" target="_blank" style="color:#c9a84c;text-decoration:none;">Manage your account</a></p>`,
}),
})
if (error) console.error('[study-signup] welcome email send error:', error)
} catch (err) {
console.error('[study-signup] welcome email exception:', err)
}
}
export async function sendStudyVerifyEmail(email, displayName, verifyUrl) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const cfg = state.cachedSiteContent ?? {}
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const subject = cfg.emailVerifySubject?.trim() || 'Verify your email address'
const bodyText = cfg.emailVerifyBody?.trim() || 'Please confirm this is your email address so we can keep your study account secure and deliver reminders you opt into.'
const ctaLabel = cfg.emailVerifyCtaLabel?.trim() || 'Verify Email Address'
const bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>`
)
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `Hi ${namePart},\n\n${bodyText}\n\n${verifyUrl}\n\nVerse by Verse with Nate`,
html: buildBrandedEmailHtml({
title: 'Verify Your Email',
eyebrow: 'Study Account',
bodyHtml,
ctaLabel,
ctaUrl: verifyUrl,
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">If you didn't create this account, you can ignore this message.</p>`,
}),
})
if (error) console.error('[study-verify-email] send error:', error)
} catch (err) {
console.error('[study-verify-email] send exception:', err)
}
}
export async function sendStudyPasswordResetEmail(email, displayName, resetUrl) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const cfg = state.cachedSiteContent ?? {}
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const subject = cfg.passwordResetSubject?.trim() || 'Reset your password'
const bodyText = cfg.passwordResetBody?.trim() || 'We received a request to reset the password on your study account. Click below to choose a new password. This link expires in 1 hour.'
const ctaLabel = cfg.passwordResetCtaLabel?.trim() || 'Reset Password'
const bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>`
)
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `Hi ${namePart},\n\n${bodyText}\n\n${resetUrl}\n\nIf you didn't request this, you can safely ignore this email — your password won't change.\n\nVerse by Verse with Nate`,
html: buildBrandedEmailHtml({
title: 'Reset Your Password',
eyebrow: 'Account Security',
bodyHtml,
ctaLabel,
ctaUrl: resetUrl,
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">If you didn't request this, you can safely ignore this email — your password won't change.</p>`,
}),
})
if (error) console.error('[study-password-reset] send error:', error)
} catch (err) {
console.error('[study-password-reset] send exception:', err)
}
}
export async function sendStudyPasswordChangedEmail(email, displayName) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const cfg = state.cachedSiteContent ?? {}
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const baseUrl = getCanonicalBaseUrl()
const accountUrl = buildAbsoluteUrl(baseUrl, '/study/account')
const subject = cfg.passwordChangedSubject?.trim() || 'Your password was changed'
const bodyText = cfg.passwordChangedBody?.trim() || 'This confirms the password on your study account was just changed. All other active sessions have been signed out. If this wasn’t you, please contact us right away.'
const bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>`
)
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `Hi ${namePart},\n\n${bodyText}\n\nManage your account: ${accountUrl}\n\nVerse by Verse with Nate`,
html: buildBrandedEmailHtml({
title: 'Password Changed',
eyebrow: 'Account Security',
bodyHtml,
ctaLabel: 'Manage Account',
ctaUrl: accountUrl,
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-password-changed] send error:', error)
} catch (err) {
console.error('[study-password-changed] send exception:', err)
}
}
export async function sendEmailOtp(email, code) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const cfg = state.cachedSiteContent ?? {}
const subject = cfg.twoFaOtpEmailSubject?.trim() || 'Your sign-in code — Verse by Verse with Nate'
const bodyText = cfg.twoFaOtpEmailBody?.trim() || 'Your two-factor sign-in code is below. Enter it to complete sign-in.'
const expiryText = cfg.twoFaOtpEmailExpiry?.trim() || 'This code expires in 10 minutes. If you did not request this, you can ignore this message.'
await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `${bodyText}\n\n${code}\n\n${expiryText}\n\nVerse by Verse with Nate`,
html: buildBrandedEmailHtml({
title: 'Your Sign-In Code',
eyebrow: 'Account Security',
bodyHtml:
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>` +
`<p style="margin:0 0 16px;font-size:2.5rem;font-weight:700;letter-spacing:0.35em;color:#c9a84c;font-family:monospace;">${code}</p>` +
`<p style="margin:0 0 16px;font-size:0.9rem;color:#7a7060;">${escapeHtml(expiryText)}</p>`,
footerHtml: `<p style="margin:0;font-family:Georgia,serif;font-size:12px;color:#7a7060;">Verse by Verse with Nate</p>`,
}),
})
} catch (err) {
console.error('[email-otp] send error:', err)
}
}
export async function sendStudyAccountDeletedEmail(email, displayName) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const cfg = state.cachedSiteContent ?? {}
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const baseUrl = getCanonicalBaseUrl()
const subject = cfg.studyDeletedEmailSubject?.trim() || 'Your study account was deleted'
const bodyText = cfg.studyDeletedEmailBody?.trim() || 'This confirms your study account and saved notes were deleted. If this was not you, please contact us immediately.'
const ctaLabel = cfg.studyDeletedEmailCtaLabel?.trim() || 'Create a New Account'
const signupUrl = buildAbsoluteUrl(baseUrl, cfg.studyDeletedEmailCtaPath?.trim() || '/study/signup')
const signoff = cfg.studyDeletedEmailSignoff?.trim() || 'Verse by Verse with Nate'
const bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>`
)
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(signoff).replace(/\n/g, '<br/>')}</p>`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `Hi ${namePart},\n\n${bodyText}\n\nCreate a new account anytime: ${signupUrl}\n\n${signoff}`,
html: buildBrandedEmailHtml({
title: 'Study Account Deleted',
eyebrow: 'Account Update',
bodyHtml,
ctaLabel,
ctaUrl: signupUrl,
footerHtml,
}),
})
if (error) console.error('[study-account] delete email send error:', error)
} catch (err) {
console.error('[study-account] delete email exception:', err)
}
}
export async function sendStudyReminderEmail(email, displayName, studyTitle, sectionTitle, sectionReference, sectionUrl) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const cfg = state.cachedSiteContent ?? {}
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const subjectPrefix = cfg.studyReminderEmailSubjectPrefix?.trim() || 'New lesson available:'
const bodyText = cfg.studyReminderEmailBody?.trim() || 'A new lesson has been unlocked in your study track. Open it below to continue where you left off.'
const ctaLabel = cfg.studyReminderEmailCtaLabel?.trim() || 'Open the Lesson'
const signoff = cfg.studyReminderEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate'
const subject = process.env.RESEND_REMINDER_SUBJECT ?? `${subjectPrefix} ${sectionTitle}`
const bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>` +
`<p style="margin:0 0 16px;"><strong style="color:#f0ead8;">${escapeHtml(sectionTitle)}</strong> (${escapeHtml(sectionReference)}) — ${escapeHtml(studyTitle)}</p>`
)
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(signoff).replace(/\n/g, '<br/>')}</p>`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `Hi ${namePart},\n\n${bodyText}\n\n${sectionTitle} (${sectionReference}) — ${studyTitle}\n\nOpen it here: ${sectionUrl}\n\n${signoff}`,
html: buildBrandedEmailHtml({
title: 'New Lesson Available',
eyebrow: 'Study Reminder',
bodyHtml,
ctaLabel,
ctaUrl: sectionUrl,
footerHtml,
}),
})
if (error) console.error('[study-reminder] send error:', error)
} catch (err) {
console.error('[study-reminder] send exception:', err)
}
}
const REENGAGEMENT_DEFAULTS = {
t1: { subject: 'Your study is waiting for you', body: "It's been a while — your progress is saved and your next lesson is ready whenever you are.", cta: 'Continue Studying' },
t2: { subject: "Don't lose your momentum", body: 'Every note and completed lesson is still right there. A little each day adds up — come back and continue.', cta: 'Return to Your Study' },
t3: { subject: 'Your progress is still here', body: "Your study progress is still intact and waiting. There's no deadline — come back whenever you're ready.", cta: 'Open Your Study' },
}
function getReengagementCopy(tier) {
const cfg = state.cachedSiteContent ?? {}
const t = tier === 't1' ? '1' : tier === 't2' ? '2' : tier === 't3' ? '3' : null
const def = REENGAGEMENT_DEFAULTS[tier] ?? REENGAGEMENT_DEFAULTS.t1
if (!t) return def
return {
subject: cfg[`studyReengagementT${t}Subject`]?.trim() || def.subject,
body: cfg[`studyReengagementT${t}Body`]?.trim() || def.body,
cta: cfg[`studyReengagementT${t}Cta`]?.trim() || def.cta,
}
}
export async function sendStudyReengagementEmail(email, displayName, studyTitle, studyUrl, tier, unsubUrl) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const copy = getReengagementCopy(tier)
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const subject = `${copy.subject}${studyTitle ? ` — ${studyTitle}` : ''}`
const bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(copy.body)}${studyTitle ? ` Your current study: <strong style="color:#f0ead8;">${escapeHtml(studyTitle)}</strong>.` : ''}</p>`
)
const unsubLine = unsubUrl ? `<p style="margin:16px 0 0;font-size:11px;color:#7a7060;">Not interested? <a href="${escapeHtml(unsubUrl)}" style="color:#7a7060;">Unsubscribe from these reminders.</a></p>` : ''
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">Grace and peace,<br/>Verse by Verse with Nate</p>${unsubLine}`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `Hi ${namePart},\n\n${copy.body}${studyTitle ? ` Your current study: ${studyTitle}.` : ''}\n\n${studyUrl}\n\nGrace and peace,\nVerse by Verse with Nate${unsubUrl ? `\n\nUnsubscribe: ${unsubUrl}` : ''}`,
html: buildBrandedEmailHtml({
title: copy.subject,
eyebrow: 'Study Reminder',
bodyHtml,
ctaLabel: copy.cta,
ctaUrl: studyUrl,
footerHtml,
}),
})
if (error) console.error('[study-reengagement] send error:', error)
} catch (err) {
console.error('[study-reengagement] send exception:', err)
}
}
export async function sendStudyCertificateEmail(email, displayName, studyTitle, certUrl) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const cfg = state.cachedSiteContent ?? {}
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const subject = cfg.studyCertificateEmailSubject?.trim() || 'Your certificate is ready'
const bodyText = cfg.studyCertificateEmailBody?.trim() || 'Congratulations — you have completed every lesson in this study. Your certificate is ready to view and share.'
const ctaLabel = cfg.studyCertificateEmailCtaLabel?.trim() || 'View Certificate'
const signoff = cfg.studyCertificateEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate'
const bodyHtml = (
`<p style="margin:0 0 16px;">Congratulations, <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>!</p>` +
`<p style="margin:0 0 16px;">${escapeHtml(bodyText)}</p>` +
`<p style="margin:0 0 16px;color:#c9a84c;font-weight:700;">${escapeHtml(studyTitle)}</p>`
)
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(signoff).replace(/\n/g, '<br/>')}</p>`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `Congratulations, ${namePart}!\n\n${bodyText}\n\n${studyTitle}\n\nView your certificate: ${certUrl}\n\n${signoff}`,
html: buildBrandedEmailHtml({
title: 'Study Complete',
eyebrow: 'Certificate of Completion',
bodyHtml,
ctaLabel,
ctaUrl: certUrl,
footerHtml,
}),
})
if (error) console.error('[study-certificate] email send error:', error)
} catch (err) {
console.error('[study-certificate] email exception:', err)
}
}
export async function sendStudyWeeklyDigestEmail(email, displayName, lessons, unsubUrl) {
if (!process.env.RESEND_API_KEY) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const cfg = state.cachedSiteContent ?? {}
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
const subject = cfg.studyWeeklyDigestEmailSubject?.trim() || 'Your weekly study digest'
const introText = cfg.studyWeeklyDigestEmailBody?.trim() || 'Here are the lessons released this week in your study track. Open any to continue where you left off.'
const signoff = cfg.studyWeeklyDigestEmailSignoff?.trim() || 'Grace and peace,\nVerse by Verse with Nate'
const lessonListHtml = lessons.map(l =>
`<p style="margin:0 0 12px;"><a href="${escapeHtml(l.sectionUrl)}" style="color:#c9a84c;text-decoration:none;font-weight:700;">${escapeHtml(l.sectionTitle)}</a>` +
(l.sectionReference ? ` <span style="color:#7a7060;">(${escapeHtml(l.sectionReference)})</span>` : '') +
(l.studyTitle ? ` <span style="color:#7a7060;font-size:0.9em;">— ${escapeHtml(l.studyTitle)}</span>` : '') +
`</p>`
).join('')
const lessonListText = lessons.map(l => `• ${l.sectionTitle}${l.sectionReference ? ` (${l.sectionReference})` : ''}: ${l.sectionUrl}`).join('\n')
const bodyHtml = (
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
`<p style="margin:0 0 20px;">${escapeHtml(introText)}</p>` +
`<div style="border-left:2px solid #2a2518;padding-left:16px;">${lessonListHtml}</div>`
)
const unsubLine = unsubUrl ? `<p style="margin:16px 0 0;font-size:11px;color:#7a7060;">Not interested? <a href="${escapeHtml(unsubUrl)}" style="color:#7a7060;">Unsubscribe from these reminders.</a></p>` : ''
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">${escapeHtml(signoff).replace(/\n/g, '<br/>')}</p>${unsubLine}`
const { error } = await resend.emails.send({
from: process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM,
to: [email],
subject,
text: `Hi ${namePart},\n\n${introText}\n\n${lessonListText}\n\n${signoff}${unsubUrl ? `\n\nUnsubscribe: ${unsubUrl}` : ''}`,
html: buildBrandedEmailHtml({
title: 'Weekly Study Digest',
eyebrow: 'Study Reminder',
bodyHtml,
footerHtml,
}),
})
if (error) console.error('[study-digest] send error:', error)
} catch (err) {
console.error('[study-digest] send exception:', err)
}
}
// ── Nuggets upload notifications ───────────────────────────────────────────
export async function sendNuggetsUploadNotification({ to, source, hostName, episodeName, passage, description, filename, infoFile }) {
if (!process.env.RESEND_API_KEY) return
const resend = new Resend(process.env.RESEND_API_KEY)
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
const sourceLabel = source === 'portal' ? 'Upload Portal' : 'Direct to Seafile'
const subject = `New Nuggets Episode Upload: ${episodeName || filename}`
const bodyHtml = `
<p style="margin:0 0 16px;font-family:Georgia,serif;font-size:15px;color:#c8c0ac;">A new episode file has arrived in Seafile.</p>
<table style="width:100%;border-collapse:collapse;font-family:Georgia,serif;font-size:14px;">
<tr><td style="padding:6px 0;color:#7a7060;width:120px;">Source</td><td style="padding:6px 0;font-weight:600;color:#f0ead8;">${escapeHtml(sourceLabel)}</td></tr>
${hostName ? `<tr><td style="padding:6px 0;color:#7a7060;">Host</td><td style="padding:6px 0;color:#c8c0ac;">${escapeHtml(hostName)}</td></tr>` : ''}
${episodeName ? `<tr><td style="padding:6px 0;color:#7a7060;">Episode</td><td style="padding:6px 0;font-weight:600;color:#f0ead8;">${escapeHtml(episodeName)}</td></tr>` : ''}
${passage ? `<tr><td style="padding:6px 0;color:#7a7060;">Passage</td><td style="padding:6px 0;color:#c8c0ac;">${escapeHtml(passage)}</td></tr>` : ''}
<tr><td style="padding:6px 0;color:#7a7060;">Audio File</td><td style="padding:6px 0;font-family:monospace;font-size:13px;color:#c9a84c;">${escapeHtml(filename)}</td></tr>
${infoFile ? `<tr><td style="padding:6px 0;color:#7a7060;">Info File</td><td style="padding:6px 0;font-family:monospace;font-size:13px;color:#7a7060;">${escapeHtml(infoFile)}</td></tr>` : ''}
</table>
${description ? `<p style="margin:16px 0 0;font-family:Georgia,serif;font-size:14px;color:#c8c0ac;"><strong style="color:#f0ead8;">Description:</strong><br/>${escapeHtml(description).replace(/\n/g, '<br/>')}</p>` : ''}
`
const textBody = [
`New Nuggets Episode Upload (${sourceLabel})`,
'',
hostName ? `Host: ${hostName}` : '',
episodeName ? `Episode: ${episodeName}` : '',
passage ? `Passage: ${passage}` : '',
`Audio File: ${filename}`,
infoFile ? `Info File: ${infoFile}` : '',
description ? `\nDescription:\n${description}` : '',
].filter(Boolean).join('\n')
try {
const { error } = await resend.emails.send({
from,
to: [to],
subject,
text: textBody,
html: buildBrandedEmailHtml({
title: 'New Episode Upload',
eyebrow: 'Nuggets',
bodyHtml,
footerHtml: '<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;color:#7a7060;">This notification was sent because a file was uploaded to the Nuggets Seafile library.</p>',
}),
})
if (error) console.error('[nuggets-notify] send error:', error)
} catch (err) {
console.error('[nuggets-notify] send exception:', err)
}
}
export async function sendNuggetsHostWelcomeEmail({ to, name, username, password, portalUrl }) {
if (!process.env.RESEND_API_KEY || !to) return
const resend = new Resend(process.env.RESEND_API_KEY)
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
const bodyHtml = `
<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:15px;color:#c8c0ac;">Hi <strong style="color:#f0ead8;">${escapeHtml(name)}</strong>,</p>
<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:15px;color:#c8c0ac;">
You've been set up as a host on the <strong style="color:#f0ead8;">Nuggets</strong> episode upload portal.
Use the details below to sign in and submit your episodes.
</p>
<table style="width:100%;border-collapse:collapse;font-family:Georgia,serif;font-size:14px;margin-bottom:20px;">
<tr><td style="padding:8px 12px;background:#1a1a15;border:1px solid #2a2518;color:#7a7060;width:110px;">Portal URL</td><td style="padding:8px 12px;background:#1a1a15;border:1px solid #2a2518;"><a href="${escapeHtml(portalUrl)}" style="color:#c9a84c;">${escapeHtml(portalUrl)}</a></td></tr>
<tr><td style="padding:8px 12px;background:#1a1a15;border:1px solid #2a2518;color:#7a7060;">Email</td><td style="padding:8px 12px;background:#1a1a15;border:1px solid #2a2518;font-family:monospace;font-weight:700;color:#f0ead8;">${escapeHtml(username)}</td></tr>
<tr><td style="padding:8px 12px;background:#1a1a15;border:1px solid #2a2518;color:#7a7060;">Password</td><td style="padding:8px 12px;background:#1a1a15;border:1px solid #2a2518;font-family:monospace;font-weight:700;color:#f0ead8;">${escapeHtml(password)}</td></tr>
</table>
<p style="margin:0 0 10px;font-family:Georgia,serif;font-size:14px;color:#c8c0ac;">When you're ready to submit an episode, fill in the episode name, the Bible passage, a short description, and attach your audio file. Everything uploads in one step.</p>
<p style="margin:0;font-family:Georgia,serif;font-size:14px;color:#7a7060;">If you have any trouble signing in, reply to this email and we'll get you sorted.</p>
`
try {
const { error } = await resend.emails.send({
from,
to: [to],
subject: 'Your Nuggets Host Upload Portal Access',
text: `Hi ${name},\n\nYou've been set up as a host on the Nuggets episode upload portal.\n\nPortal URL: ${portalUrl}\nUsername: ${username}\nPassword: ${password}\n\nWhen submitting an episode, fill in the episode name, Bible passage, a short description, and attach your audio file.\n\nIf you have any trouble, reply to this email.`,
html: buildBrandedEmailHtml({
title: 'Upload Portal Access',
eyebrow: 'Nuggets',
bodyHtml,
ctaLabel: 'Open Upload Portal',
ctaUrl: portalUrl,
footerHtml: '<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;color:#7a7060;">You received this because an admin created an upload account for you on the Nuggets podcast portal.</p>',
}),
})
if (error) console.error('[nuggets-welcome] send error:', error)
} catch (err) {
console.error('[nuggets-welcome] send exception:', err)
}
}
export async function sendNuggetsHostOtpEmail({ to, name, code }) {
if (!process.env.RESEND_API_KEY || !to) return
const resend = new Resend(process.env.RESEND_API_KEY)
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
const bodyHtml = `
<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:15px;color:#c8c0ac;">Hi <strong style="color:#f0ead8;">${escapeHtml(name)}</strong>,</p>
<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:15px;color:#c8c0ac;">
Your one-time sign-in code for the Nuggets host portal is below. It expires in 10 minutes.
</p>
<div style="margin:0 0 24px;padding:20px 24px;background:#1a1a15;border:1px solid #2a2518;border-radius:4px;text-align:center;">
<span style="font-family:monospace;font-size:36px;font-weight:700;letter-spacing:8px;color:#c9a84c;">${escapeHtml(code)}</span>
</div>
<p style="margin:0;font-family:Georgia,serif;font-size:14px;color:#7a7060;">If you did not request this code, you can safely ignore this email.</p>
`
try {
const { error } = await resend.emails.send({
from,
to: [to],
subject: 'Your Nuggets Portal Sign-In Code',
text: `Hi ${name},\n\nYour one-time sign-in code is: ${code}\n\nIt expires in 10 minutes. If you did not request this, ignore this email.`,
html: buildBrandedEmailHtml({
title: 'Sign-In Code',
eyebrow: 'Nuggets',
bodyHtml,
footerHtml: '<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;color:#7a7060;">You received this because a sign-in was attempted on the Nuggets podcast host portal.</p>',
}),
})
if (error) console.error('[nuggets-otp] send error:', error)
} catch (err) {
console.error('[nuggets-otp] send exception:', err)
}
}
-33
View File
@@ -1,33 +0,0 @@
// Episode scripts are filed by season + episode number ("2-1"), because each book of the Bible
// is its own podcast season and episode numbers can restart in every season. A script with no
// season keeps its plain episode number as its key.
export function scriptKey(season, episodeNumber) {
const ep = String(episodeNumber ?? '').trim()
const s = String(season ?? '').trim()
return /^\d+$/.test(s) ? `${s}-${ep}` : ep
}
// The episode number a stored script belongs to (new entries record it; legacy ones are keyed by it).
export function scriptEpisodeNumber(key, entry) {
if (entry && typeof entry.episodeNumber === 'string' && entry.episodeNumber) return entry.episodeNumber
const parts = String(key).split('-')
return parts[parts.length - 1]
}
// One-time move of legacy scripts ({ "7": { season: "1" } }) to season-episode keys ({ "1-7": ... }).
// Scripts without a season stay under their plain episode number. Never overwrites an existing key.
export function migrateScriptKeys(scripts) {
const next = {}
let changed = false
for (const [key, entry] of Object.entries(scripts ?? {})) {
if (!entry || typeof entry !== 'object') continue
const episodeNumber = scriptEpisodeNumber(key, entry)
const wanted = scriptKey(entry.season, episodeNumber)
const targetKey = wanted !== key && !(wanted in scripts) && !(wanted in next) ? wanted : key
const withEpisode = entry.episodeNumber === episodeNumber ? entry : { ...entry, episodeNumber }
if (targetKey !== key || withEpisode !== entry) changed = true
next[targetKey] = withEpisode
}
return { scripts: next, changed }
}
-89
View File
@@ -1,89 +0,0 @@
import { fetchAllEpisodes } from './routes/episodes.js'
import { loadPodcastWorkflow, laneSeasonOf } from './routes/podcast-workflow.js'
// The Spotify (Anchor) RSS feed only lists episodes that are live, so a season + episode number
// showing up there means the episode has been published. That drives two things on the public site:
// - highlights marked "hold until released" stay hidden until their episode is in the feed
// - highlights and study lessons with no audio of their own pick it up from the matching feed episode
const SNAPSHOT_TTL_MS = 5 * 60 * 1000
let snapshot = { items: [], at: 0 }
let inflight = null
function refreshSnapshot() {
if (!inflight) {
inflight = fetchAllEpisodes()
.then(items => { snapshot = { items, at: Date.now() } })
// Keep serving the last good copy; retry on the next TTL window
.catch(() => { snapshot = { items: snapshot.items, at: Date.now() } })
.finally(() => { inflight = null })
}
return inflight
}
export async function getFeedIndex() {
if (!snapshot.at) await refreshSnapshot()
else if (Date.now() - snapshot.at > SNAPSHOT_TTL_MS) refreshSnapshot()
const index = new Map()
for (const item of snapshot.items) {
if (item.season && /^\d+$/.test(item.episode)) index.set(`${item.season}-${item.episode}`, item)
}
return index
}
export function warmFeedSnapshot() {
return refreshSnapshot()
}
// Returns a copy of the published site content with the feed applied. Run it before
// filterSiteContentByReleaseDate so audio is never added to a lesson that is still locked.
export async function applyFeedRelease(content) {
if (!content || typeof content !== 'object' || Array.isArray(content)) return content
const links = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
const studies = Array.isArray(content.studies) ? content.studies : []
if (links.length === 0 && studies.length === 0) return content
const [index, wf] = await Promise.all([getFeedIndex(), loadPodcastWorkflow()])
const seasonA = laneSeasonOf('A', wf, content)
const seasonB = laneSeasonOf('B', wf, content)
const nextLinks = links.flatMap(link => {
// General highlights aren't tied to a series, so there's no season to match against
if (!link || link.lane === 'general') return [link]
const season = link.lane === 'second' ? seasonB : seasonA
const item = season && link.episodeNumber ? index.get(`${season}-${String(link.episodeNumber).trim()}`) : null
if (!item) return link.holdUntilReleased ? [] : [link]
// Highlights with no audio of their own pick it up from the matching feed episode
return [{
...link,
url: link.url || item.link || '',
embedUrl: link.embedUrl?.trim() ? link.embedUrl : (item.audioUrl || link.embedUrl),
}]
})
const studyLane = study => {
if (study?.lane === 'A' || study?.lane === 'B') return study.lane
if (study?.slug && study.slug === content.seriesStudySlug) return 'A'
if (study?.slug && study.slug === content.secondSeries?.studySlug) return 'B'
return ''
}
const nextStudies = studies.map(study => {
const lane = studyLane(study)
const season = lane === 'A' ? seasonA : lane === 'B' ? seasonB : ''
if (!season || !Array.isArray(study.sections)) return study
return {
...study,
sections: study.sections.map(section => {
if (!section || section.audioEmbedUrl?.trim() || !section.episodeNumber) return section
const item = index.get(`${season}-${String(section.episodeNumber).trim()}`)
return item?.audioUrl ? { ...section, audioEmbedUrl: item.audioUrl } : section
}),
}
})
return { ...content, podcastFeaturedLinks: nextLinks, studies: nextStudies }
}
-630
View File
@@ -1,630 +0,0 @@
import { randomUUID } from 'node:crypto'
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_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', '/about', '/contact', '/episodes', '/resources', '/study', '/study/titus', '/study/colossians'],
}
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 function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
export function splitName(fullName) {
const parts = String(fullName).trim().split(/\s+/).filter(Boolean)
return {
firstName: parts[0] ?? '',
lastName: parts.slice(1).join(' '),
}
}
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 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 normalizeSitemapPath(value) {
if (typeof value !== 'string') return ''
const trimmed = value.trim()
if (!trimmed) return ''
if (trimmed === '/') return '/'
return normalizeRedirectPath(trimmed)
}
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
}
export function sanitizeFeaturedLinks(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => {
const discussionQuestions = Array.isArray(item.discussionQuestions)
? item.discussionQuestions
.filter(question => typeof question === 'string')
.map(question => question.trim())
.filter(Boolean)
.slice(0, 30)
: []
const sanitizeStringArray = (val, limit) =>
Array.isArray(val)
? val.filter(s => typeof s === 'string').map(s => s.trim()).filter(Boolean).slice(0, limit)
: []
return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
lane: item.lane === 'second' || item.lane === 'general' ? item.lane : 'primary',
holdUntilReleased: item.holdUntilReleased === true,
title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '',
episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '',
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
url: sanitizeUrl(item.url),
embedUrl: sanitizeUrl(item.embedUrl),
showNotes: typeof item.showNotes === 'string' ? item.showNotes.trim().slice(0, 10000) : '',
showNotesSummary: typeof item.showNotesSummary === 'string' ? item.showNotesSummary.trim().slice(0, 1000) : '',
showNotesThemes: sanitizeStringArray(item.showNotesThemes, 20),
showNotesScripture: sanitizeStringArray(item.showNotesScripture, 30),
showNotesQuotes: sanitizeStringArray(item.showNotesQuotes, 20),
showNotesResources: sanitizeStringArray(item.showNotesResources, 30),
discussionQuestions,
}
})
.filter(
item =>
item.title ||
item.summary ||
item.url ||
item.embedUrl ||
item.showNotes ||
item.showNotesSummary ||
item.showNotesThemes.length > 0 ||
item.showNotesScripture.length > 0 ||
item.showNotesQuotes.length > 0 ||
item.showNotesResources.length > 0 ||
item.discussionQuestions.length > 0,
)
}
function sanitizeCustomLinks(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => {
const placement =
item?.placement === 'platforms'
|| item?.placement === 'footer'
|| item?.placement === 'resources'
|| item?.placement === 'otherSites'
|| item?.placement === 'externalSites'
|| item?.placement === 'books'
|| item?.placement === 'recommended'
? item.placement
: 'footer'
return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
label: typeof item.label === 'string' ? item.label.trim().slice(0, 140) : '',
url: sanitizeUrl(item.url),
imageUrl: sanitizeUrl(item.imageUrl),
description: typeof item.description === 'string' ? item.description.trim().slice(0, 4000) : '',
amazonUrl: sanitizeUrl(item.amazonUrl),
amazonLabel: typeof item.amazonLabel === 'string' ? item.amazonLabel.trim().slice(0, 120) : '',
appleBooksUrl: sanitizeUrl(item.appleBooksUrl),
appleBooksLabel: typeof item.appleBooksLabel === 'string' ? item.appleBooksLabel.trim().slice(0, 120) : '',
tags: Array.isArray(item.tags)
? item.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean).slice(0, 20)
: [],
placement,
}
})
.filter(item => item.label && (item.url || item.amazonUrl || item.placement === 'books' || item.placement === 'recommended' || item.placement === 'resources'))
}
function sanitizeCustomBlocks(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '',
body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '',
page: item?.page === 'homepage' || item?.page === 'start-here' || item?.page === 'episodes' || item?.page === 'downloads' || item?.page === 'about' || item?.page === 'contact' || item?.page === 'questions'
? item.page
: 'downloads',
linkUrl: sanitizeUrl(item.linkUrl),
linkLabel: typeof item.linkLabel === 'string' ? item.linkLabel.trim().slice(0, 120) : '',
}))
.filter(item => item.heading || item.body)
}
function sanitizeArchivedSeriesResourceLinks(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
label: typeof item.label === 'string' ? item.label.trim().slice(0, 120) : '',
description: typeof item.description === 'string' ? item.description.trim().slice(0, 4000) : '',
url: sanitizeUrl(item.url),
amazonUrl: sanitizeUrl(item.amazonUrl),
amazonLabel: typeof item.amazonLabel === 'string' ? item.amazonLabel.trim().slice(0, 120) : '',
}))
.filter(item => item.label && item.url)
}
function sanitizeArchivedSeriesNotes(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '',
body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '',
}))
.filter(item => item.heading || item.body)
}
function sanitizeColossiansStudySections(value) {
function normalizeReleasedAt(value) {
if (typeof value !== 'string' || !value.trim()) return ''
const parsed = Date.parse(value.trim())
if (!Number.isFinite(parsed)) return ''
return new Date(parsed).toISOString().replace('.000Z', 'Z')
}
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => {
const releasedAt = normalizeReleasedAt(item.releasedAt)
return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
chapter: Number.isInteger(item.chapter) && item.chapter >= 1 && item.chapter <= 999 ? item.chapter : 1,
reference: typeof item.reference === 'string' ? item.reference.trim().slice(0, 40) : '',
title: typeof item.title === 'string' ? item.title.trim().slice(0, 160) : '',
episodeNumber: typeof item.episodeNumber === 'string' || typeof item.episodeNumber === 'number' ? String(item.episodeNumber).trim().slice(0, 20) : '',
audioEmbedUrl: sanitizeUrl(item.audioEmbedUrl),
passageText: typeof item.passageText === 'string' ? item.passageText.trim().slice(0, 12000) : '',
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
commentary: typeof item.commentary === 'string' ? item.commentary.trim().slice(0, 12000) : '',
greekNotes: Array.isArray(item.greekNotes)
? item.greekNotes.filter(note => typeof note === 'string').map(note => note.trim()).filter(Boolean).slice(0, 20)
: [],
studyQuestions: Array.isArray(item.studyQuestions)
? item.studyQuestions.filter(question => typeof question === 'string').map(question => question.trim()).filter(Boolean).slice(0, 20)
: [],
announcement: typeof item.announcement === 'string' ? item.announcement.trim().slice(0, 600) : '',
checkpointPrompt: typeof item.checkpointPrompt === 'string' ? item.checkpointPrompt.trim().slice(0, 600) : '',
checkpointQuestions: Array.isArray(item.checkpointQuestions)
? item.checkpointQuestions.filter(question => typeof question === 'string').map(question => question.trim()).filter(Boolean).slice(0, 20)
: [],
...(releasedAt ? { releasedAt } : {}),
}
})
.filter(item => item.title || item.summary || item.commentary || item.greekNotes.length > 0 || item.studyQuestions.length > 0 || item.passageText || item.checkpointPrompt || item.checkpointQuestions.length > 0)
}
function sanitizeIsoDate(value) {
if (typeof value !== 'string' || !value.trim()) return ''
const time = Date.parse(value)
return Number.isFinite(time) ? new Date(time).toISOString() : ''
}
function sanitizeStudyGuide(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const str = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '')
const title = str(value.title, 140)
if (!title) return undefined
return {
title,
description: str(value.description, 4000),
downloadUrl: sanitizeUrl(value.downloadUrl),
amazonUrl: sanitizeUrl(value.amazonUrl),
amazonLabel: str(value.amazonLabel, 80),
appleBooksUrl: sanitizeUrl(value.appleBooksUrl),
appleBooksLabel: str(value.appleBooksLabel, 80),
}
}
function sanitizeSecondSeries(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const str = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '')
const season = Number.isInteger(value.season) && value.season >= 1 && value.season <= 999 ? value.season : undefined
return {
label: str(value.label, 80) || 'Now Playing',
title: str(value.title, 160),
description: str(value.description, 800),
imageUrl: str(value.imageUrl, 500),
listenUrl: str(value.listenUrl, 500),
listenBtnLabel: str(value.listenBtnLabel, 80),
studySlug: str(value.studySlug, 120).toLowerCase().replace(/[^a-z0-9-]/g, '-'),
...(season ? { season } : {}),
...(sanitizeStudyGuide(value.guide) ? { guide: sanitizeStudyGuide(value.guide) } : {}),
}
}
function sanitizeStudies(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
slug: typeof item.slug === 'string' ? item.slug.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') : '',
title: typeof item.title === 'string' ? item.title.trim().slice(0, 160) : '',
description: typeof item.description === 'string' ? item.description.trim().slice(0, 800) : '',
homepageEyebrow: typeof item.homepageEyebrow === 'string' ? item.homepageEyebrow.trim().slice(0, 80) : '',
imageUrl: sanitizeUrl(item.imageUrl) || '',
showOnHomepage: item.showOnHomepage === true,
showNewTag: item.showNewTag === true,
newTagLabel: typeof item.newTagLabel === 'string' ? item.newTagLabel.trim().slice(0, 24) : '',
guideSource: typeof item.guideSource === 'string' && /^(none|primary|second|book:[A-Za-z0-9_-]{1,80})$/.test(item.guideSource.trim()) ? item.guideSource.trim() : '',
status: item.status === 'planned' ? 'planned' : (item.status === 'complete' ? 'complete' : 'active'),
lane: item.lane === 'A' || item.lane === 'B' ? item.lane : '',
startsAt: sanitizeIsoDate(item.startsAt),
difficulty: item.difficulty === 'advanced' ? 'advanced' : (item.difficulty === 'intermediate' ? 'intermediate' : 'beginner'),
estimatedHours: Number.isFinite(Number(item.estimatedHours)) ? Math.max(1, Math.min(500, Number(item.estimatedHours))) : 8,
completionBadge: typeof item.completionBadge === 'string' ? item.completionBadge.trim().slice(0, 120) : '',
numberOfChapters: Number.isInteger(item.numberOfChapters) && item.numberOfChapters >= 1 && item.numberOfChapters <= 999 ? item.numberOfChapters : 1,
sections: sanitizeColossiansStudySections(item.sections),
}))
.filter(item => item.slug && item.title)
}
function sanitizeArchivedSeries(value) {
const source = Array.isArray(value) ? value : []
return source
.filter(item => item && typeof item === 'object')
.map(item => ({
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
label: typeof item.label === 'string' ? item.label.trim().slice(0, 140) : '',
title: typeof item.title === 'string' ? item.title.trim().slice(0, 200) : '',
description: typeof item.description === 'string' ? item.description.trim().slice(0, 1000) : '',
imageUrl: sanitizeUrl(item.imageUrl),
listenUrl: sanitizeUrl(item.listenUrl),
studyGuideTitle: typeof item.studyGuideTitle === 'string' ? item.studyGuideTitle.trim().slice(0, 140) : '',
studyGuideDescription: typeof item.studyGuideDescription === 'string' ? item.studyGuideDescription.trim().slice(0, 4000) : '',
studyGuideUrl: sanitizeUrl(item.studyGuideUrl),
resourceLinks: sanitizeArchivedSeriesResourceLinks(item.resourceLinks),
notes: sanitizeArchivedSeriesNotes(item.notes),
episodeRange:
Number.isInteger(item?.episodeRange?.from)
&& Number.isInteger(item?.episodeRange?.to)
&& item.episodeRange.from > 0
&& item.episodeRange.to >= item.episodeRange.from
? { from: item.episodeRange.from, to: item.episodeRange.to }
: undefined,
}))
.filter(
item =>
item.title ||
item.description ||
item.resourceLinks.length > 0 ||
item.notes.length > 0,
)
}
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, 4000) : '',
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 {}
const seo = siteContent.seo && typeof siteContent.seo === 'object' ? siteContent.seo : {}
const legal = siteContent.legal && typeof siteContent.legal === 'object' ? siteContent.legal : {}
return {
...siteContent,
customLinks: sanitizeCustomLinks(siteContent.customLinks),
customBlocks: sanitizeCustomBlocks(siteContent.customBlocks),
archivedSeries: sanitizeArchivedSeries(siteContent.archivedSeries),
studies: sanitizeStudies(siteContent.studies),
secondSeries: sanitizeSecondSeries(siteContent.secondSeries),
finishedBooks: Array.isArray(siteContent.finishedBooks)
? siteContent.finishedBooks.map(book => (book && typeof book === 'object' ? { ...book, guide: sanitizeStudyGuide(book.guide) } : book))
: siteContent.finishedBooks,
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) : '',
welcomeEmailStartHereLinkLabel: typeof siteContent.welcomeEmailStartHereLinkLabel === 'string' ? siteContent.welcomeEmailStartHereLinkLabel.trim().slice(0, 80) : '',
studyWelcomeEmailSubject: typeof siteContent.studyWelcomeEmailSubject === 'string' ? siteContent.studyWelcomeEmailSubject.trim().slice(0, 200) : '',
studyWelcomeEmailBody: typeof siteContent.studyWelcomeEmailBody === 'string' ? siteContent.studyWelcomeEmailBody.trim().slice(0, 1000) : '',
studyWelcomeEmailCtaLabel: typeof siteContent.studyWelcomeEmailCtaLabel === 'string' ? siteContent.studyWelcomeEmailCtaLabel.trim().slice(0, 80) : '',
studyWelcomeEmailCtaPath: typeof siteContent.studyWelcomeEmailCtaPath === 'string' ? siteContent.studyWelcomeEmailCtaPath.trim().slice(0, 200) : '',
studyWelcomeEmailSignoff: typeof siteContent.studyWelcomeEmailSignoff === 'string' ? siteContent.studyWelcomeEmailSignoff.trim().slice(0, 200) : '',
studyDeletedEmailSubject: typeof siteContent.studyDeletedEmailSubject === 'string' ? siteContent.studyDeletedEmailSubject.trim().slice(0, 200) : '',
studyDeletedEmailBody: typeof siteContent.studyDeletedEmailBody === 'string' ? siteContent.studyDeletedEmailBody.trim().slice(0, 1000) : '',
studyDeletedEmailCtaLabel: typeof siteContent.studyDeletedEmailCtaLabel === 'string' ? siteContent.studyDeletedEmailCtaLabel.trim().slice(0, 80) : '',
studyDeletedEmailCtaPath: typeof siteContent.studyDeletedEmailCtaPath === 'string' ? siteContent.studyDeletedEmailCtaPath.trim().slice(0, 200) : '',
studyDeletedEmailSignoff: typeof siteContent.studyDeletedEmailSignoff === 'string' ? siteContent.studyDeletedEmailSignoff.trim().slice(0, 200) : '',
studyReminderEmailSubjectPrefix: typeof siteContent.studyReminderEmailSubjectPrefix === 'string' ? siteContent.studyReminderEmailSubjectPrefix.trim().slice(0, 200) : '',
studyReminderEmailBody: typeof siteContent.studyReminderEmailBody === 'string' ? siteContent.studyReminderEmailBody.trim().slice(0, 1000) : '',
studyReminderEmailCtaLabel: typeof siteContent.studyReminderEmailCtaLabel === 'string' ? siteContent.studyReminderEmailCtaLabel.trim().slice(0, 80) : '',
studyReminderEmailSignoff: typeof siteContent.studyReminderEmailSignoff === 'string' ? siteContent.studyReminderEmailSignoff.trim().slice(0, 200) : '',
emailChangeSubject: typeof siteContent.emailChangeSubject === 'string' ? siteContent.emailChangeSubject.trim().slice(0, 200) : '',
emailChangeBody: typeof siteContent.emailChangeBody === 'string' ? siteContent.emailChangeBody.trim().slice(0, 500) : '',
emailChangeCtaLabel: typeof siteContent.emailChangeCtaLabel === 'string' ? siteContent.emailChangeCtaLabel.trim().slice(0, 80) : '',
twoFaOtpEmailSubject: typeof siteContent.twoFaOtpEmailSubject === 'string' ? siteContent.twoFaOtpEmailSubject.trim().slice(0, 200) : '',
twoFaOtpEmailBody: typeof siteContent.twoFaOtpEmailBody === 'string' ? siteContent.twoFaOtpEmailBody.trim().slice(0, 500) : '',
twoFaOtpEmailExpiry: typeof siteContent.twoFaOtpEmailExpiry === 'string' ? siteContent.twoFaOtpEmailExpiry.trim().slice(0, 300) : '',
seo: {
title: typeof seo.title === 'string' && seo.title.trim() ? seo.title.trim().slice(0, 120) : DEFAULT_SEO.title,
description: typeof seo.description === 'string' && seo.description.trim() ? seo.description.trim().slice(0, 240) : DEFAULT_SEO.description,
ogTitle: typeof seo.ogTitle === 'string' && seo.ogTitle.trim() ? seo.ogTitle.trim().slice(0, 120) : DEFAULT_SEO.ogTitle,
ogDescription: typeof seo.ogDescription === 'string' && seo.ogDescription.trim() ? seo.ogDescription.trim().slice(0, 240) : DEFAULT_SEO.ogDescription,
ogImage: sanitizeUrl(seo.ogImage) || DEFAULT_SEO.ogImage,
canonicalUrl: sanitizeUrl(seo.canonicalUrl) || DEFAULT_SEO.canonicalUrl,
robotsPolicy: typeof seo.robotsPolicy === 'string' && seo.robotsPolicy.trim() ? seo.robotsPolicy.trim() : DEFAULT_SEO.robotsPolicy,
sitemapPaths: Array.isArray(seo.sitemapPaths)
? seo.sitemapPaths.map(pathItem => normalizeSitemapPath(pathItem)).filter(Boolean)
: [...DEFAULT_SEO.sitemapPaths],
},
legal: {
privacyTitle: typeof legal.privacyTitle === 'string' && legal.privacyTitle.trim() ? legal.privacyTitle.trim().slice(0, 120) : DEFAULT_LEGAL.privacyTitle,
privacyBody: Array.isArray(legal.privacyBody) && legal.privacyBody.length > 0
? legal.privacyBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20)
: [...DEFAULT_LEGAL.privacyBody],
termsTitle: typeof legal.termsTitle === 'string' && legal.termsTitle.trim() ? legal.termsTitle.trim().slice(0, 120) : DEFAULT_LEGAL.termsTitle,
termsBody: Array.isArray(legal.termsBody) && legal.termsBody.length > 0
? legal.termsBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20)
: [...DEFAULT_LEGAL.termsBody],
},
}
}
export function escapeXml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
export function buildAbsoluteUrl(baseUrl, maybeRelativePath) {
const safeBase = typeof baseUrl === 'string' && baseUrl.trim() ? baseUrl.trim() : DEFAULT_SEO.canonicalUrl
const root = safeBase.endsWith('/') ? safeBase.slice(0, -1) : safeBase
if (typeof maybeRelativePath !== 'string' || !maybeRelativePath.trim()) return root
const value = maybeRelativePath.trim()
if (/^https?:\/\//i.test(value)) return value
if (value.startsWith('/')) return `${root}${value}`
return `${root}/${value}`
}
export function injectSeoIntoHtml(html, siteContent) {
const seo = siteContent?.seo ?? DEFAULT_SEO
const title = seo.title || DEFAULT_SEO.title
const description = seo.description || DEFAULT_SEO.description
const ogTitle = seo.ogTitle || title
const ogDescription = seo.ogDescription || description
const canonical = buildAbsoluteUrl(seo.canonicalUrl || DEFAULT_SEO.canonicalUrl, '/')
const ogImage = buildAbsoluteUrl(canonical, seo.ogImage || DEFAULT_SEO.ogImage)
const robots = seo.robotsPolicy || DEFAULT_SEO.robotsPolicy
return html
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${escapeHtml(title)}</title>`)
.replace(/<meta name="description" content="[^"]*"\s*\/?>/i, `<meta name="description" content="${escapeHtml(description)}" />`)
.replace(/<meta name="robots" content="[^"]*"\s*\/?>/i, `<meta name="robots" content="${escapeHtml(robots)}" />`)
.replace(/<meta property="og:title" content="[^"]*"\s*\/?>/i, `<meta property="og:title" content="${escapeHtml(ogTitle)}" />`)
.replace(/<meta property="og:description" content="[^"]*"\s*\/?>/i, `<meta property="og:description" content="${escapeHtml(ogDescription)}" />`)
.replace(/<meta property="og:image" content="[^"]*"\s*\/?>/i, `<meta property="og:image" content="${escapeHtml(ogImage)}" />`)
.replace(/<meta property="og:image:secure_url" content="[^"]*"\s*\/?>/i, `<meta property="og:image:secure_url" content="${escapeHtml(ogImage)}" />`)
.replace(/<meta property="og:url" content="[^"]*"\s*\/?>/i, `<meta property="og:url" content="${escapeHtml(canonical)}" />`)
.replace(/<link rel="canonical" href="[^"]*"\s*\/?>/i, `<link rel="canonical" href="${escapeHtml(canonical)}" />`)
}
export function normalizeAssetBaseName(name) {
if (typeof name !== 'string') return `upload-${Date.now()}`
const cleaned = name
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
return cleaned || `upload-${Date.now()}`
}
export function inferImageExtensionFromDataUrl(dataUrl) {
if (typeof dataUrl !== 'string') return null
if (dataUrl.startsWith('data:image/png;base64,')) return '.png'
if (dataUrl.startsWith('data:image/jpeg;base64,')) return '.jpg'
if (dataUrl.startsWith('data:image/webp;base64,')) return '.webp'
if (dataUrl.startsWith('data:image/gif;base64,')) return '.gif'
if (dataUrl.startsWith('data:application/pdf;base64,')) return '.pdf'
if (dataUrl.startsWith('data:application/msword;base64,')) return '.doc'
if (dataUrl.startsWith('data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,')) return '.docx'
return 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 getClientIp(req) {
// Use req.ip: Express derives this from x-forwarded-for according to the
// configured trust proxy hop count, preventing header spoofing by clients.
return normalizeIp(req.ip)
}
export function parseCookies(cookieHeader) {
if (!cookieHeader) return {}
return cookieHeader
.split(';')
.map(v => v.trim())
.filter(Boolean)
.reduce((acc, part) => {
const idx = part.indexOf('=')
if (idx === -1) return acc
const key = part.slice(0, idx).trim()
const value = part.slice(idx + 1).trim()
try {
acc[key] = decodeURIComponent(value)
} catch {
acc[key] = value
}
return acc
}, {})
}
export function hasVisitorConsent(req) {
const cookies = parseCookies(req.headers.cookie)
return cookies['vbn_analytics_consent'] === 'yes'
}
// Secure cookies are required in production unless explicitly disabled with
// ALLOW_INSECURE_COOKIES=true — needed when the app is reached over plain
// HTTP (e.g. by LAN/VPN IP during a server migration, before TLS is set up),
// because browsers silently drop Secure cookies on http:// origins.
export function cookieSecureFlag() {
if (process.env.ALLOW_INSECURE_COOKIES === 'true') return ''
return process.env.NODE_ENV === 'production' ? '; Secure' : ''
}
export function setConsentCookie(res, consent) {
const value = consent ? 'yes' : 'no'
res.append('Set-Cookie', `vbn_analytics_consent=${value}; Max-Age=31536000; Path=/; SameSite=Lax${cookieSecureFlag()}`)
}
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'
)
}
-94
View File
@@ -1,94 +0,0 @@
import { readFile } from 'node:fs/promises'
import { writeFile } from './atomic-write.js'
import { sendNuggetsUploadNotification } from './email.js'
import {
SEAFILE_URL,
SEAFILE_TOKEN,
SEAFILE_NUGGETS_REPO_ID,
SEAFILE_EPISODE_UPLOAD_DIR,
SEAFILE_EPISODE_INFO_DIR,
SEAFILE_POLL_INTERVAL_MS,
NUGGETS_SEAFILE_SEEN_FILE,
NUGGETS_NOTIFY_EMAIL,
} from './config.js'
async function loadSeen() {
try {
return JSON.parse(await readFile(NUGGETS_SEAFILE_SEEN_FILE, 'utf8'))
} catch {
return { files: [] }
}
}
async function saveSeen(data) {
await writeFile(NUGGETS_SEAFILE_SEEN_FILE, JSON.stringify(data, null, 2), 'utf8')
}
// Called by the upload route after a successful portal upload so the poller
// doesn't also fire a notification for the same file.
export async function markSeafileFileSeen(filename) {
const data = await loadSeen()
if (!data.files.includes(filename)) {
data.files.push(filename)
await saveSeen(data)
}
}
async function listSeafileDir(dir) {
if (!SEAFILE_URL || !SEAFILE_TOKEN || !SEAFILE_NUGGETS_REPO_ID) return []
try {
const url = `${SEAFILE_URL}/api2/repos/${SEAFILE_NUGGETS_REPO_ID}/dir/?p=${encodeURIComponent(dir)}`
const res = await fetch(url, {
headers: { Authorization: `Token ${SEAFILE_TOKEN}` },
signal: AbortSignal.timeout(10_000),
})
if (!res.ok) return []
const data = await res.json()
return (data.dirent ?? []).filter(e => e.type === 'file').map(e => e.name)
} catch {
return []
}
}
async function poll() {
if (!SEAFILE_URL || !SEAFILE_TOKEN || !SEAFILE_NUGGETS_REPO_ID) return
const [uploadFiles, seen] = await Promise.all([
listSeafileDir(SEAFILE_EPISODE_UPLOAD_DIR),
loadSeen(),
])
const seenSet = new Set(seen.files)
const newFiles = uploadFiles.filter(f => !seenSet.has(f))
if (newFiles.length === 0) return
for (const filename of newFiles) {
console.log(`[nuggets-watcher] new direct upload detected: ${filename}`)
await sendNuggetsUploadNotification({
to: NUGGETS_NOTIFY_EMAIL,
source: 'direct',
filename,
})
seenSet.add(filename)
}
seen.files = [...seenSet]
await saveSeen(seen)
}
export function startNuggetsSeafileWatcher() {
if (!SEAFILE_URL || !SEAFILE_TOKEN || !SEAFILE_NUGGETS_REPO_ID) {
console.log('[nuggets-watcher] Seafile not configured — watcher disabled.')
return
}
// Initial poll after 2 min (let the server settle), then every 15 min
setTimeout(() => {
poll().catch(err => console.error('[nuggets-watcher] poll error:', err))
setInterval(() => {
poll().catch(err => console.error('[nuggets-watcher] poll error:', err))
}, SEAFILE_POLL_INTERVAL_MS)
}, 2 * 60 * 1000)
console.log(`[nuggets-watcher] started — polling every ${SEAFILE_POLL_INTERVAL_MS / 60000} min`)
}
-15
View File
@@ -1,15 +0,0 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// Base directory resolution lives in its own module (no app imports) so that
// both config.js and auth.js can use DATA_DIR without a circular dependency.
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// server/paths.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
-53
View File
@@ -1,53 +0,0 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand, CopyObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'
import { R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, R2_PUBLIC_URL } from './config.js'
function makeClient() {
return new S3Client({
region: 'auto',
endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: { accessKeyId: R2_ACCESS_KEY_ID, secretAccessKey: R2_SECRET_ACCESS_KEY },
})
}
export function isR2Configured() {
return Boolean(R2_ACCOUNT_ID && R2_ACCESS_KEY_ID && R2_SECRET_ACCESS_KEY && R2_BUCKET && R2_PUBLIC_URL)
}
export async function uploadToR2(buffer, key, contentType) {
await makeClient().send(new PutObjectCommand({
Bucket: R2_BUCKET,
Key: key,
Body: buffer,
ContentType: contentType,
}))
return `${R2_PUBLIC_URL}/${key}`
}
export async function deleteFromR2(key) {
await makeClient().send(new DeleteObjectCommand({ Bucket: R2_BUCKET, Key: key }))
}
export async function copyInR2(fromKey, toKey) {
await makeClient().send(new CopyObjectCommand({
Bucket: R2_BUCKET,
CopySource: `${R2_BUCKET}/${fromKey.split('/').map(encodeURIComponent).join('/')}`,
Key: toKey,
}))
return `${R2_PUBLIC_URL}/${toKey}`
}
export async function listR2Objects(prefix) {
const objects = []
let continuationToken
const client = makeClient()
do {
const resp = await client.send(new ListObjectsV2Command({
Bucket: R2_BUCKET,
Prefix: prefix,
ContinuationToken: continuationToken,
}))
for (const obj of resp.Contents ?? []) objects.push(obj)
continuationToken = resp.IsTruncated ? resp.NextContinuationToken : undefined
} while (continuationToken)
return objects
}
-339
View File
@@ -1,339 +0,0 @@
import { mkdir, rename, stat, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { inferImageExtensionFromDataUrl, normalizeAssetBaseName } from '../helpers.js'
import { requireAdminAuth } from '../auth.js'
import { UPLOADS_DIR } from '../config.js'
import { isR2Configured, uploadToR2, deleteFromR2, copyInR2 } from '../r2.js'
import { state } from '../state.js'
import {
listUploadedAssets,
readUploadsMetadata,
writeUploadsMetadata,
getUserNotesFilePath,
loadUserProgress,
queueStudyUsersWrite,
} from '../data.js'
import {
hashStudyPassword,
getStudyCatalog,
normalizeStudySlug,
isEnrollableStudySlug,
makeUnsubUrl,
invalidateAllStudySessions,
} from '../study-helpers.js'
import { sendStudyReengagementEmail, sendStudyPasswordChangedEmail } from '../email.js'
const ALLOWED_FOLDERS = ['uploads', 'images', 'guides', 'finished']
function isSafeFilename(filename) {
return typeof filename === 'string' && filename.length > 0 && !filename.includes('/') && !filename.includes('\\') && !filename.includes('..')
}
export function register(app) {
app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => {
try {
const assets = await listUploadedAssets()
const haystack = JSON.stringify(state.cachedDraftSiteContent ?? {}) + JSON.stringify(state.cachedSiteContent ?? {})
const withUsage = assets.map(asset => ({
...asset,
usedIn: asset.url ? haystack.split(asset.url).length - 1 : 0,
}))
res.json({ assets: withUsage })
} catch {
res.status(500).json({ message: 'Could not list uploaded assets.' })
}
})
app.post('/api/admin-assets', requireAdminAuth, async (req, res) => {
try {
const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
const rawFolder = typeof req.body?.folder === 'string' ? req.body.folder : 'uploads'
const rawStorage = typeof req.body?.storage === 'string' ? req.body.storage : 'r2'
const ext = inferImageExtensionFromDataUrl(dataUrl)
const folder = ALLOWED_FOLDERS.includes(rawFolder) ? rawFolder : 'uploads'
const useLocal = rawStorage === 'local' || !isR2Configured()
if (!ext) {
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, GIF, PDF, DOC, or DOCX data URL.' })
return
}
const base64 = dataUrl.split(',')[1] ?? ''
const buffer = Buffer.from(base64, 'base64')
if (buffer.length === 0 || buffer.length > (8 * 1024 * 1024)) {
res.status(400).json({ message: 'Upload must be between 1 byte and 8MB.' })
return
}
const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, ''))
const finalName = `${baseName}-${Date.now()}${ext}`
let url
if (useLocal) {
await mkdir(UPLOADS_DIR, { recursive: true })
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
url = `/uploads/${finalName}`
} else {
const contentType = dataUrl.match(/^data:([^;]+);base64,/)?.[1] ?? 'application/octet-stream'
url = await uploadToR2(buffer, `${folder}/${finalName}`, contentType)
}
const metadata = await readUploadsMetadata()
metadata[finalName] = { tags: [], url, folder: useLocal ? 'local' : folder }
await writeUploadsMetadata(metadata)
res.json({ ok: true, asset: { filename: finalName, url, folder: useLocal ? 'local' : folder, storage: useLocal ? 'local' : 'r2' } })
} catch (err) {
console.error('[admin-assets] upload failed:', err)
res.status(500).json({ message: 'Upload failed.' })
}
})
app.patch('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
try {
const { filename } = req.params
if (!isSafeFilename(filename)) {
res.status(400).json({ message: 'Invalid filename.' })
return
}
const metadata = await readUploadsMetadata()
const current = (await listUploadedAssets()).find(a => a.filename === filename)
if (!current) {
res.status(404).json({ message: 'Asset not found.' })
return
}
const entry = metadata[filename]
let tags = Array.isArray(entry)
? entry
: Array.isArray(entry?.tags) ? entry.tags : []
if (Array.isArray(req.body?.tags)) {
tags = req.body.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean)
}
let newFilename = filename
let newUrl = current.url
let newFolder = current.folder
const ext = path.extname(filename)
const wantsRename = typeof req.body?.newName === 'string' && req.body.newName.trim()
const wantsMove = typeof req.body?.folder === 'string' && req.body.folder !== current.folder
if (wantsRename) {
const base = normalizeAssetBaseName(req.body.newName.trim().replace(/\.[a-z0-9]+$/i, ''))
newFilename = `${base}${ext}`
}
if (wantsMove) {
if (current.storage !== 'r2' || !ALLOWED_FOLDERS.includes(req.body.folder)) {
res.status(400).json({ message: 'Only R2 assets can be moved, to uploads, images, guides, or finished.' })
return
}
newFolder = req.body.folder
}
if (newFilename !== filename || newFolder !== current.folder) {
if (newFilename !== filename) {
const taken = (await listUploadedAssets()).some(a => a.filename === newFilename && a.folder === newFolder)
if (taken) {
res.status(409).json({ message: `A file named ${newFilename} already exists there.` })
return
}
}
if (current.storage === 'r2') {
const newKey = `${newFolder}/${newFilename}`
newUrl = await copyInR2(current.key, newKey)
await deleteFromR2(current.key)
} else {
await rename(path.join(UPLOADS_DIR, filename), path.join(UPLOADS_DIR, newFilename))
newUrl = `/uploads/${newFilename}`
}
delete metadata[filename]
}
metadata[newFilename] = { tags, url: newUrl, folder: current.storage === 'r2' ? newFolder : 'local' }
await writeUploadsMetadata(metadata)
res.json({ ok: true, tags, filename: newFilename, url: newUrl, folder: newFolder, oldUrl: current.url })
} catch (err) {
console.error('[admin-assets] update failed:', err)
res.status(500).json({ message: 'Could not update asset.' })
}
})
app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
try {
const { filename } = req.params
if (!isSafeFilename(filename)) {
res.status(400).json({ message: 'Invalid filename.' })
return
}
// Optional ?folder= disambiguates identical filenames in different R2 folders.
const folder = typeof req.query.folder === 'string' ? req.query.folder : null
const matches = (await listUploadedAssets()).filter(a => a.filename === filename && (!folder || a.folder === folder))
const target = matches[0]
if (!target) {
res.status(404).json({ message: 'Asset not found.' })
return
}
if (target.storage === 'r2') {
await deleteFromR2(target.key)
} else {
await unlink(path.join(UPLOADS_DIR, filename)).catch(() => {})
}
const metadata = await readUploadsMetadata()
delete metadata[filename]
await writeUploadsMetadata(metadata)
res.json({ ok: true })
} catch (err) {
console.error('[admin-assets] delete failed:', err)
res.status(500).json({ message: 'Delete failed.' })
}
})
// ── Admin: Study User Management ─────────────────────────────────────────
app.get('/api/admin/study-users', requireAdminAuth, async (req, res) => {
const catalog = getStudyCatalog()
const users = await Promise.all(state.studyUsers.map(async user => {
let noteCount = 0
try {
const { readFile } = await import('node:fs/promises')
const notesRaw = await readFile(getUserNotesFilePath(user.id), 'utf8').catch(() => '{}')
const notes = JSON.parse(notesRaw)
noteCount = Object.values(notes).filter(n => typeof n === 'string' && n.trim()).length
} catch { /* ignore */ }
let progressByStudy = {}
try {
const prog = await loadUserProgress(user.id)
for (const [slug, data] of Object.entries(prog.byStudy)) {
progressByStudy[slug] = { completedLessons: (data.completedSectionIds ?? []).length }
}
} catch { /* ignore */ }
const enrolledStudies = (user.enrolledStudySlugs ?? []).map(slug => {
const study = catalog.find(s => s.slug === slug)
return study
? { slug, title: study.title, completedLessons: progressByStudy[slug]?.completedLessons ?? 0 }
: { slug, title: slug, completedLessons: 0 }
})
return {
id: user.id,
username: user.username,
displayName: user.displayName ?? '',
createdAt: user.createdAt ?? null,
lastLoginAt: user.lastLoginAt ?? null,
enrolledStudies,
noteCount,
subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true,
reminderFrequency: user.reminderFrequency ?? 'immediate',
remindersPausedUntil: user.remindersPausedUntil ?? null,
currentStreak: user.currentStreak ?? 0,
longestStreak: user.longestStreak ?? 0,
reengagementSentAt: user.reengagementSentAt ?? {},
adminShortcut: user.adminShortcut === true,
}
}))
res.json({ users })
})
app.post('/api/admin/study-users/:id/send-reengagement', requireAdminAuth, async (req, res) => {
const user = state.studyUsers.find(u => u.id === req.params.id)
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
const tier = req.body?.tier
if (tier !== 't1' && tier !== 't2' && tier !== 't3') {
res.status(400).json({ message: 'tier must be t1, t2, or t3.' }); return
}
const studies = Array.isArray(state.cachedSiteContent?.studies) ? state.cachedSiteContent.studies : []
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
const enrolledStudies = enrolledStudySlugs
.map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug))
.filter(Boolean)
if (enrolledStudies.length === 0) { res.status(400).json({ message: 'User has no enrolled studies.' }); return }
const firstStudy = enrolledStudies[0]
const studyTitle = enrolledStudies.length === 1 ? firstStudy.title : null
const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/'
const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
const studyUrl = enrolledStudies.length === 1 ? `${base}/study/${firstStudy.slug}` : `${base}/study`
const unsubUrl = makeUnsubUrl(user.id)
try {
await sendStudyReengagementEmail(user.username, user.displayName || user.username, studyTitle, studyUrl, tier, unsubUrl)
res.json({ ok: true })
} catch (err) {
console.error(`[admin] manual reengagement send failed for ${user.username}:`, err)
res.status(500).json({ message: 'Failed to send email.' })
}
})
app.patch('/api/admin/study-users/:id', requireAdminAuth, (req, res) => {
const user = state.studyUsers.find(u => u.id === req.params.id)
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
const { displayName, newPassword, addEnrollment, removeEnrollment, adminShortcut } = req.body ?? {}
if (typeof displayName === 'string') {
user.displayName = displayName.trim().slice(0, 80)
}
if (typeof adminShortcut === 'boolean') {
user.adminShortcut = adminShortcut
}
if (typeof newPassword === 'string') {
if (newPassword.length < 8 || newPassword.length > 200) {
res.status(400).json({ message: 'Password must be 8–200 characters.' }); return
}
user.passwordHash = hashStudyPassword(newPassword)
invalidateAllStudySessions(user.id)
sendStudyPasswordChangedEmail(user.username, user.displayName || user.username)
.catch(err => console.error('[admin] password-changed email error:', err))
}
if (typeof addEnrollment === 'string' && addEnrollment.trim()) {
const slug = normalizeStudySlug(addEnrollment)
if (!slug || !isEnrollableStudySlug(slug)) {
res.status(400).json({ message: `Unknown study slug: ${addEnrollment.trim()}` }); return
}
if (!Array.isArray(user.enrolledStudySlugs)) user.enrolledStudySlugs = []
if (!user.enrolledStudySlugs.includes(slug)) user.enrolledStudySlugs.push(slug)
}
if (typeof removeEnrollment === 'string' && removeEnrollment.trim()) {
const slug = removeEnrollment.trim()
user.enrolledStudySlugs = (user.enrolledStudySlugs ?? []).filter(s => s !== slug)
}
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs, adminShortcut: user.adminShortcut === true })
})
app.delete('/api/admin/study-users/:id', requireAdminAuth, async (req, res) => {
const user = state.studyUsers.find(u => u.id === req.params.id)
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
invalidateAllStudySessions(user.id)
state.studyUsers = state.studyUsers.filter(u => u.id !== user.id)
queueStudyUsersWrite()
state.studyNotesCache.delete(user.id)
try { await unlink(getUserNotesFilePath(user.id)) } catch { /* no notes file is fine */ }
res.json({ ok: true })
})
}
-164
View File
@@ -1,164 +0,0 @@
import rateLimit from 'express-rate-limit'
import qrcode from 'qrcode'
import { parseCookies } from '../helpers.js'
import { APP_VERSION, GIT_COMMIT } from '../config.js'
import {
ADMIN_SESSION_COOKIE,
isAdminPasswordConfigured,
isValidAdminSession,
requireAdminAuth,
setAdminSessionCookie,
clearAdminSessionCookie,
createAdminSession,
deleteAdminSession,
isAdminPasswordValid,
isTotpEnabled,
loadTotpState,
saveTotpState,
generateTotpSecret,
getTotpUri,
verifyTotpCode,
generateRecoveryCodes,
hashRecoveryCode,
consumeRecoveryCode,
createPendingSession,
consumePendingSession,
} from '../auth.js'
const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many login attempts. Please wait 15 minutes and try again.' },
skipSuccessfulRequests: true,
})
export function register(app) {
app.get('/api/admin-auth/status', async (req, res) => {
res.json({
authenticated: isValidAdminSession(req),
configured: isAdminPasswordConfigured(),
totpEnabled: await isTotpEnabled(),
version: APP_VERSION,
commit: GIT_COMMIT,
})
})
app.post('/api/admin-auth/login', loginRateLimiter, async (req, res) => {
const password = typeof req.body?.password === 'string' ? req.body.password : ''
if (!isAdminPasswordConfigured()) {
res.status(503).json({ message: 'ADMIN_PASSWORD is not configured on the server.' })
return
}
if (!isAdminPasswordValid(password)) {
res.status(401).json({ message: 'Invalid password.' })
return
}
const totpOn = await isTotpEnabled()
if (totpOn) {
const pendingToken = createPendingSession()
res.json({ totpRequired: true, pendingToken })
return
}
const sessionToken = createAdminSession()
setAdminSessionCookie(res, sessionToken)
res.json({ ok: true })
})
app.post('/api/admin-auth/totp-verify', loginRateLimiter, async (req, res) => {
const { pendingToken, code } = req.body ?? {}
if (!consumePendingSession(pendingToken)) {
res.status(401).json({ message: 'Session expired or invalid. Please sign in again.' })
return
}
const totpState = await loadTotpState()
if (!totpState?.secret || !totpState?.verified) {
res.status(400).json({ message: 'TOTP is not configured.' })
return
}
const codeStr = typeof code === 'string' ? code.trim() : ''
if (verifyTotpCode(totpState.secret, codeStr)) {
const sessionToken = createAdminSession()
setAdminSessionCookie(res, sessionToken)
res.json({ ok: true })
return
}
if (consumeRecoveryCode(totpState, codeStr)) {
await saveTotpState(totpState)
const sessionToken = createAdminSession()
setAdminSessionCookie(res, sessionToken)
res.json({ ok: true, usedRecoveryCode: true, remainingRecoveryCodes: totpState.hashedRecoveryCodes.length })
return
}
res.status(401).json({ message: 'Invalid code. Try again or use a recovery code.' })
})
app.post('/api/admin-auth/totp-setup-init', requireAdminAuth, async (req, res) => {
const secret = generateTotpSecret()
const uri = getTotpUri(secret)
const qrDataUrl = await qrcode.toDataURL(uri)
const existing = await loadTotpState()
await saveTotpState({ ...existing, secret, verified: false })
res.json({ qrDataUrl, secret })
})
app.post('/api/admin-auth/totp-setup-confirm', requireAdminAuth, async (req, res) => {
const { code } = req.body ?? {}
const totpState = await loadTotpState()
if (!totpState?.secret) {
res.status(400).json({ message: 'No TOTP setup in progress. Call /totp-setup-init first.' })
return
}
if (!verifyTotpCode(totpState.secret, typeof code === 'string' ? code.trim() : '')) {
res.status(401).json({ message: 'Code incorrect. Scan the QR code again and try once more.' })
return
}
const recoveryCodes = generateRecoveryCodes()
await saveTotpState({
secret: totpState.secret,
verified: true,
hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode),
enabledAt: new Date().toISOString(),
})
res.json({ ok: true, recoveryCodes })
})
app.post('/api/admin-auth/totp-disable', requireAdminAuth, async (req, res) => {
await saveTotpState({ secret: null, verified: false, hashedRecoveryCodes: [], disabledAt: new Date().toISOString() })
res.json({ ok: true })
})
app.post('/api/admin-auth/totp-regen-recovery', requireAdminAuth, async (req, res) => {
const totpState = await loadTotpState()
if (!totpState?.secret || !totpState?.verified) {
res.status(400).json({ message: 'TOTP is not enabled.' })
return
}
const recoveryCodes = generateRecoveryCodes()
await saveTotpState({ ...totpState, hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode) })
res.json({ ok: true, recoveryCodes })
})
app.post('/api/admin-auth/logout', (req, res) => {
const cookies = parseCookies(req.headers.cookie)
const sessionToken = cookies[ADMIN_SESSION_COOKIE]
deleteAdminSession(sessionToken)
clearAdminSessionCookie(res)
res.json({ ok: true })
})
}
-248
View File
@@ -1,248 +0,0 @@
import { mkdir, mkdtemp, readdir, rm, cp, writeFile, unlink } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import express from 'express'
import * as tar from 'tar'
import { requireAdminAuth } from '../auth.js'
import { DATA_DIR } from '../config.js'
import { state } from '../state.js'
import {
loadHitStatsFromDisk,
loadVisitorStatsFromDisk,
loadContactSubmissionsFromDisk,
loadReplyTemplatesFromDisk,
loadReplyHistoryFromDisk,
loadQuestionsFromDisk,
loadDraftQuestionsFromDisk,
loadStudyUsersFromDisk,
loadStudyCommunityFromDisk,
loadStudyRemindersFromDisk,
loadStudyCommentsFromDisk,
loadStudyCertificatesFromDisk,
loadEpisodeScriptsFromDisk,
migrateStudyNotesIfNeeded,
loadDownloadCountsFromDisk,
loadQrCodesFromDisk,
loadEpisodePlaysFromDisk,
loadAnalyticsEventsFromDisk,
createBackupSnapshot,
refreshContentCaches,
} from '../data.js'
// Portable server settings captured into env-snapshot.env at export time so a
// backup carries the runtime configuration too (Docker deployments have no
// .env file — settings live in container env vars). Machine-specific vars
// (PORT, NODE_ENV, SITEFORGE_DATA_DIR, ALLOW_INSECURE_COOKIES, build info)
// are deliberately excluded. Values are NOT applied automatically on restore.
const ENV_SNAPSHOT_KEYS = [
'ADMIN_PASSWORD',
'RESEND_API_KEY',
'RESEND_CONTACTS_API_KEY',
'RESEND_WEBHOOK_TOKEN',
'INBOUND_EMAIL_SECRET',
'RESEND_FROM',
'RESEND_TO',
'RESEND_REPLY_TO',
'RESEND_AUTOMATION_WELCOME',
'RESEND_SEGMENT_ID',
'RESEND_REMINDER_SUBJECT',
'RESEND_WELCOME_SUBJECT',
'RESEND_WELCOME_IMAGE_URL',
'RESEND_WELCOME_EPISODE_URL',
'RESEND_WELCOME_WEBSITE_URL',
'RESEND_WELCOME_SPOTIFY_URL',
'RESEND_WELCOME_APPLE_URL',
'RESEND_WELCOME_AMAZON_URL',
'CACHE_PURGE_WEBHOOK_URL',
'DEPLOY_WEBHOOK_URL',
'CONTACT_EMAIL_COOLDOWN_MS',
'TRUST_PROXY_HOPS',
'TITUS_STUDY_FILE',
'TITUS_STUDY_DOWNLOAD_NAME',
]
// Write env snapshot into DATA_DIR for inclusion in the archive, then delete
// it immediately after the stream finishes so secrets don't linger on disk.
async function writeEnvSnapshot() {
const lines = [
'# Siteforge environment snapshot — regenerated on every full backup export.',
'# Contains secrets (admin password, API keys): keep this backup private.',
'# These values are NOT applied automatically on restore. Set them as',
'# container environment variables (or in .env) on the new server.',
`# Exported at ${new Date().toISOString()}`,
'',
]
for (const key of ENV_SNAPSHOT_KEYS) {
const value = process.env[key]
if (typeof value === 'string' && value.trim() !== '') lines.push(`${key}=${value}`)
}
await writeFile(path.join(DATA_DIR, 'env-snapshot.env'), `${lines.join('\n')}\n`, 'utf8')
}
async function deleteEnvSnapshot() {
await unlink(path.join(DATA_DIR, 'env-snapshot.env')).catch(() => {})
}
// Files that identify an archive as a Siteforge data backup. At least one
// must be present at the top level of an uploaded archive before we restore.
const KNOWN_DATA_FILES = [
'admin-content.json',
'admin-content-draft.json',
'study-users.json',
'hit-stats.json',
'visitor-stats.json',
'questions.json',
]
// Wait for every queued disk write so the archive reflects current state.
async function flushPendingWrites() {
await Promise.allSettled([
state.hitStatsWritePromise,
state.visitorStatsWritePromise,
state.contactSubmissionsWritePromise,
state.questionsWritePromise,
state.draftQuestionsWritePromise,
state.replyTemplatesWritePromise,
state.replyHistoryWritePromise,
state.studyUsersWritePromise,
state.studyCommunityWritePromise,
state.studyRemindersWritePromise,
state.studyCommentsWritePromise,
state.studyCertificatesWritePromise,
state.episodeScriptsWritePromise,
state.downloadCountsWritePromise,
state.episodePlaysWritePromise,
state.analyticsEventsWritePromise,
state.qrCodesWritePromise,
...state.studyNotesWriteQueues.values(),
...state.studyProgressWriteQueues.values(),
])
}
// Rebuild all in-memory state from whatever is now on disk (mirrors startup).
async function reloadStateFromDisk() {
state.studyNotesCache.clear()
state.studyNotesWriteQueues.clear()
state.studyProgressCache.clear()
state.studyProgressWriteQueues.clear()
// Restored study users may not match current sessions — force re-login.
state.studySessions.clear()
await Promise.all([
loadHitStatsFromDisk(),
loadVisitorStatsFromDisk(),
loadContactSubmissionsFromDisk(),
loadReplyTemplatesFromDisk(),
loadReplyHistoryFromDisk(),
loadQuestionsFromDisk(),
loadDraftQuestionsFromDisk(),
loadStudyUsersFromDisk(),
loadStudyCommunityFromDisk(),
loadStudyRemindersFromDisk(),
loadStudyCommentsFromDisk(),
loadStudyCertificatesFromDisk(),
loadEpisodeScriptsFromDisk(),
migrateStudyNotesIfNeeded(),
loadDownloadCountsFromDisk(),
loadQrCodesFromDisk(),
loadEpisodePlaysFromDisk(),
loadAnalyticsEventsFromDisk(),
refreshContentCaches(),
])
}
export function register(app) {
// Download the entire data directory as a tar.gz (excluding the automatic
// snapshot folder, which is derived from the other files).
app.get('/api/admin-backup/export', requireAdminAuth, async (_req, res) => {
try {
await flushPendingWrites()
await writeEnvSnapshot()
const entries = (await readdir(DATA_DIR)).filter(name => name !== 'backups')
if (entries.length === 0) {
res.status(500).json({ message: 'Data directory is empty — nothing to export.' })
return
}
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
res.setHeader('Content-Type', 'application/gzip')
res.setHeader('Content-Disposition', `attachment; filename="siteforge-data-${stamp}.tar.gz"`)
const archive = tar.create({ gzip: true, cwd: DATA_DIR, portable: true }, entries)
archive.on('error', err => {
console.error('[admin-backup] export stream failed:', err)
res.destroy(err)
})
archive.on('end', () => { deleteEnvSnapshot() })
res.on('close', () => { deleteEnvSnapshot() })
archive.pipe(res)
} catch (err) {
console.error('[admin-backup] export failed:', err)
deleteEnvSnapshot()
if (!res.headersSent) res.status(500).json({ message: 'Full backup export failed.' })
}
})
// Restore the entire data directory from an uploaded tar.gz produced by the
// export endpoint, then reload all in-memory state from the restored files.
app.post(
'/api/admin-backup/import',
requireAdminAuth,
express.raw({ type: () => true, limit: '500mb' }),
async (req, res) => {
let workDir = null
try {
const body = req.body
if (!Buffer.isBuffer(body) || body.length === 0) {
res.status(400).json({ message: 'Upload the .tar.gz file produced by the full backup export.' })
return
}
if (body[0] !== 0x1f || body[1] !== 0x8b) {
res.status(400).json({ message: 'File is not a gzip archive (.tar.gz expected).' })
return
}
workDir = await mkdtemp(path.join(os.tmpdir(), 'siteforge-import-'))
const archivePath = path.join(workDir, 'import.tar.gz')
await writeFile(archivePath, body)
const extractDir = path.join(workDir, 'extracted')
await mkdir(extractDir)
// node-tar strips absolute paths and rejects entries that escape cwd.
await tar.extract({ file: archivePath, cwd: extractDir })
const extractedEntries = await readdir(extractDir)
if (!extractedEntries.some(name => KNOWN_DATA_FILES.includes(name))) {
res.status(400).json({ message: 'Archive does not look like a Siteforge data backup.' })
return
}
// Settle queued writes so nothing overwrites the restored files, and
// keep a snapshot of the pre-import state in the backups folder.
await flushPendingWrites()
await createBackupSnapshot('pre-import')
// Replace current data with the archive contents. The snapshot folder
// is preserved unless the archive itself contains one.
const currentEntries = await readdir(DATA_DIR)
for (const name of currentEntries) {
if (name === 'backups' && !extractedEntries.includes('backups')) continue
await rm(path.join(DATA_DIR, name), { recursive: true, force: true })
}
for (const name of extractedEntries) {
await cp(path.join(extractDir, name), path.join(DATA_DIR, name), { recursive: true })
}
await reloadStateFromDisk()
await createBackupSnapshot('post-import')
res.json({ ok: true, restoredEntries: extractedEntries.length })
} catch (err) {
console.error('[admin-backup] import failed:', err)
res.status(500).json({ message: 'Full data restore failed. Check the server logs — data may need manual attention.' })
} finally {
if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {})
}
},
)
}
-547
View File
@@ -1,547 +0,0 @@
import { applyFeedRelease } from '../feed-release.js'
import { mkdir, readFile } from 'node:fs/promises'
import { writeFile } from '../atomic-write.js'
import { assertPublicHttpsUrl } from '../url-safety.js'
import { sanitizeSiteContent } from '../helpers.js'
import { requireAdminAuth, isValidAdminSession } from '../auth.js'
import {
DATA_DIR,
DATA_FILE,
DRAFT_DATA_FILE,
QUESTIONS_FILE,
DEFAULT_SEO,
DEFAULT_LEGAL,
DEFAULT_REDIRECT_RULES,
DEFAULT_PODCAST_FEATURED_LINKS,
MAX_QUESTIONS,
EMPTY_HIT_STATS,
EMPTY_VISITOR_STATS,
} from '../config.js'
import { state } from '../state.js'
import {
loadSiteContentFile,
getStorageStatus,
refreshContentCaches,
queueHitStatsWrite,
queueVisitorStatsWrite,
queueContactSubmissionsWrite,
queueReplyTemplatesWrite,
queueReplyHistoryWrite,
queueWebhooksWrite,
createBackupSnapshot,
listBackupPreviews,
readBackupPreview,
restoreFromBackup,
} from '../data.js'
import {
pruneStatsByDays,
filterSiteContentByReleaseDate,
} from '../study-helpers.js'
function invokeWebhook(url, action) {
if (!url) {
return Promise.resolve({ ok: false, message: `${action} webhook URL is not configured.` })
}
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, at: new Date().toISOString(), source: 'siteforge-admin' }),
})
.then(response => {
if (!response.ok) return { ok: false, message: `${action} webhook failed with ${response.status}.` }
return { ok: true, message: `${action} webhook triggered.` }
})
.catch(err => ({ ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` }))
}
// The site can run two series at once ("lanes"): the primary series (flat series* fields)
// and an optional second series (secondSeries). Each lane has its own scheduled transition.
const SERIES_LANES = [
{ lane: 'primary', scheduleKey: 'scheduledSeriesTransition' },
{ lane: 'second', scheduleKey: 'scheduledSecondSeriesTransition' },
]
function readCurrentSeries(content, lane) {
if (lane === 'second') {
const s = content.secondSeries ?? {}
return {
title: s.title ?? '', description: s.description ?? '', imageUrl: s.imageUrl ?? '',
season: s.season, studySlug: s.studySlug ?? '',
guide: s.guide?.title ? s.guide : undefined,
}
}
return {
title: content.seriesTitle ?? '', description: content.seriesDescription ?? '', imageUrl: content.seriesImageUrl ?? '',
season: content.seriesSeason, studySlug: content.seriesStudySlug ?? '',
// The main series' guide lives in the flat studyGuide* fields
guide: content.studyGuideTitle?.trim()
? {
title: content.studyGuideTitle,
description: content.studyGuideDescription ?? '',
downloadUrl: content.studyGuideDownloadUrl ?? '',
amazonUrl: content.studyGuideUrl ?? '',
amazonLabel: content.studyGuideAmazonButtonLabel ?? '',
appleBooksUrl: content.studyGuideAppleBooksUrl ?? '',
appleBooksLabel: content.studyGuideAppleBooksLabel ?? '',
}
: undefined,
}
}
// Builds the content that results from applying one lane's scheduled series transition.
// Only the named lane changes; the other lane's series, studies and highlights are left alone.
function applySeriesTransition(content, sched, lane = 'primary') {
const scheduleKey = lane === 'second' ? 'scheduledSecondSeriesTransition' : 'scheduledSeriesTransition'
const current = readCurrentSeries(content, lane)
const newSeason = Number.isInteger(sched.newSeason) ? sched.newSeason : undefined
const newStudySlug = typeof sched.newStudySlug === 'string' ? sched.newStudySlug.trim().toLowerCase() : ''
// Highlights for the ending series move onto its Finished Book; the other lane's and general ones stay.
const allHighlights = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
const isThisLane = link => (link.lane === 'second' ? 'second' : link.lane === 'general' ? 'general' : 'primary') === lane
const endingHighlights = current.title.trim() ? allHighlights.filter(isThisLane) : []
const keptHighlights = current.title.trim() ? allHighlights.filter(link => !isThisLane(link)) : allHighlights
const archivedBook = current.title.trim()
? {
id: Date.now().toString(36),
title: current.title,
description: current.description,
imageUrl: current.imageUrl,
season: current.season ?? ((newSeason ?? 2) - 1 || 1),
...(current.studySlug ? { studySlug: current.studySlug } : {}),
...(current.guide ? { guide: current.guide } : {}),
...(endingHighlights.length > 0 ? { highlights: endingHighlights.map(({ lane: _lane, ...link }) => link) } : {}),
}
: null
const patch = {}
const copy = (from, to) => {
if (typeof sched[from] === 'string' && sched[from].trim()) patch[to] = sched[from].trim()
}
copy('newStartHereHeading', 'startHereHeading')
copy('newStartHereIntro', 'startHereIntro')
for (const n of [1, 2, 3]) {
copy(`newStartHereStep${n}Title`, `startHereStep${n}Title`)
copy(`newStartHereStep${n}Body`, `startHereStep${n}Body`)
copy(`newStartHereStep${n}Cta`, `startHereStep${n}Cta`)
}
copy('newStudyGuideTitle', 'studyGuideTitle')
copy('newStudyGuideDescription', 'studyGuideDescription')
copy('newStudyGuideDownloadUrl', 'studyGuideDownloadUrl')
copy('newStudyGuideUrl', 'studyGuideUrl')
copy('newStudyGuideAmazonButtonLabel', 'studyGuideAmazonButtonLabel')
copy('newStudyGuideAppleBooksUrl', 'studyGuideAppleBooksUrl')
copy('newStudyGuideAppleBooksLabel', 'studyGuideAppleBooksLabel')
// The finished series' study becomes Complete (still enrollable, self-paced); the
// incoming series' study opens up, taking the lane it launches in.
const laneLetter = lane === 'second' ? 'B' : 'A'
const studies = (Array.isArray(content.studies) ? content.studies : []).map(study => {
if (!study) return study
if (current.studySlug && study.slug === current.studySlug && study.status !== 'planned') {
return { ...study, status: 'complete' }
}
if (newStudySlug && study.slug === newStudySlug) {
return { ...study, status: 'active', lane: study.lane || laneLetter, startsAt: '' }
}
return study
})
const lanePatch = lane === 'second'
? {
secondSeries: {
...(content.secondSeries ?? {}),
label: 'Now Playing',
title: sched.newTitle.trim(),
description: sched.newDescription?.trim() ?? '',
imageUrl: sched.newImageUrl?.trim() || content.secondSeries?.imageUrl || '',
studySlug: newStudySlug,
// The ending series' guide moved onto its Finished Book; the incoming series brings its own
guide: sched.newGuide?.title?.trim() ? sched.newGuide : undefined,
...(newSeason ? { season: newSeason } : {}),
},
}
: {
seriesTitle: sched.newTitle.trim(),
seriesDescription: sched.newDescription?.trim() ?? '',
seriesImageUrl: sched.newImageUrl?.trim() || content.seriesImageUrl || '',
seriesLabel: 'Now Playing',
seriesStudySlug: newStudySlug,
...(newSeason ? { seriesSeason: newSeason } : {}),
// The ending series' guide moved onto its Finished Book; unless the launch brings a new one, clear it
...(current.guide && !sched.newStudyGuideTitle?.trim()
? {
studyGuideTitle: '', studyGuideDescription: '', studyGuideDownloadUrl: '', studyGuideUrl: '',
studyGuideAmazonButtonLabel: '', studyGuideAppleBooksUrl: '', studyGuideAppleBooksLabel: '',
}
: {}),
}
const next = sanitizeSiteContent({
...content,
...lanePatch,
studies,
podcastFeaturedLinks: keptHighlights,
finishedBooks: [...(Array.isArray(content.finishedBooks) ? content.finishedBooks : []), ...(archivedBook ? [archivedBook] : [])],
...patch,
})
delete next[scheduleKey]
return { next, archivedBook }
}
// Applies any scheduled series transitions (one per lane) whose triggersAt time has passed.
// A schedule is saved with the draft (Save Schedule), so look in the draft as well as
// published content. Applies to both so a later publish doesn't undo it.
// Called on a server interval (see server.js). Safe to call if no transition is set.
export async function runScheduledSeriesTransition() {
const published = state.cachedSiteContent
const draft = state.cachedDraftSiteContent
if (!published) return
let nextPublished = published
let nextDraft = draft
const fired = []
for (const { lane, scheduleKey } of SERIES_LANES) {
const sched = nextDraft?.[scheduleKey] ?? nextPublished[scheduleKey]
if (!sched || !sched.triggersAt || !sched.newTitle?.trim()) continue
const triggerTime = new Date(sched.triggersAt).getTime()
if (Number.isNaN(triggerTime) || triggerTime > Date.now()) continue
console.log(`[series-scheduler] Firing scheduled ${lane} series transition to:`, sched.newTitle)
const result = applySeriesTransition(nextPublished, sched, lane)
nextPublished = result.next
if (nextDraft) nextDraft = applySeriesTransition(nextDraft, sched, lane).next
fired.push(result.archivedBook?.title ?? '(nothing archived)')
}
if (fired.length === 0) return
const publishedAt = new Date().toISOString()
try {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(DATA_FILE, JSON.stringify({ siteContent: nextPublished, updatedAt: publishedAt }, null, 2), 'utf8')
state.cachedSiteContent = nextPublished
state.publishState.publishedAt = publishedAt
console.log('[series-scheduler] Transition complete — archived:', fired.join(', '))
} catch (err) {
console.error('[series-scheduler] Failed to write transition:', err)
return
}
if (nextDraft && nextDraft !== draft) {
try {
await writeFile(DRAFT_DATA_FILE, JSON.stringify({ siteContent: nextDraft, updatedAt: publishedAt }, null, 2), 'utf8')
state.cachedDraftSiteContent = nextDraft
state.publishState.draftUpdatedAt = publishedAt
} catch (err) {
console.error('[series-scheduler] Failed to update draft after transition:', err)
}
}
}
export function register(app) {
app.get('/api/admin-content', async (req, res) => {
const source = req.query?.source === 'draft' ? 'draft' : 'published'
if (source === 'draft' && !isValidAdminSession(req)) {
res.status(401).json({ message: 'Unauthorized' })
return
}
try {
const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE)
if (source === 'published') {
const safeSiteContent = filterSiteContentByReleaseDate(await applyFeedRelease(parsed.siteContent))
res.json({ ...parsed, siteContent: safeSiteContent })
return
}
res.json(parsed)
} catch {
if (source === 'draft') {
res.status(404).json({ message: 'No saved draft content file yet.' })
return
}
res.status(404).json({ message: 'No saved admin content file yet.' })
}
})
app.get('/api/admin-content-state', requireAdminAuth, (_req, res) => {
res.json({
publishState: state.publishState,
hasDraft: Boolean(state.cachedDraftSiteContent),
hasPublished: Boolean(state.cachedSiteContent),
})
})
app.get('/api/admin-storage-status', requireAdminAuth, async (_req, res) => {
const status = await getStorageStatus()
res.json(status)
})
app.get('/api/site-config', async (_req, res) => {
try {
const parsed = await loadSiteContentFile(DATA_FILE)
const siteContent = await applyFeedRelease(parsed.siteContent ?? {})
res.json({
seo: siteContent.seo ?? DEFAULT_SEO,
legal: siteContent.legal ?? DEFAULT_LEGAL,
redirects: siteContent.redirects ?? DEFAULT_REDIRECT_RULES,
podcastFeaturedLinks: siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS,
publishState: state.publishState,
updatedAt: parsed.updatedAt ?? null,
})
} catch {
res.json({
seo: DEFAULT_SEO,
legal: DEFAULT_LEGAL,
redirects: DEFAULT_REDIRECT_RULES,
podcastFeaturedLinks: DEFAULT_PODCAST_FEATURED_LINKS,
publishState: state.publishState,
updatedAt: null,
})
}
})
app.put('/api/admin-content-draft', requireAdminAuth, async (req, res) => {
try {
const { siteContent } = req.body ?? {}
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) {
res.status(400).json({ message: 'Invalid payload: siteContent must be an object.' })
return
}
const safeSiteContent = sanitizeSiteContent(siteContent)
const updatedAt = new Date().toISOString()
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
DRAFT_DATA_FILE,
JSON.stringify({ siteContent: safeSiteContent, updatedAt }, null, 2),
'utf8',
)
state.cachedDraftSiteContent = safeSiteContent
state.publishState.draftUpdatedAt = updatedAt
res.json({ ok: true, updatedAt })
} catch (err) {
console.error('[admin-content-draft] persist error:', err)
const reason = err instanceof Error ? err.message : 'Unknown write error'
res.status(500).json({ message: `Failed to persist admin draft content to ${DATA_DIR}: ${reason}` })
}
})
app.post('/api/admin-content/publish', requireAdminAuth, async (_req, res) => {
try {
const source = state.cachedDraftSiteContent
? { siteContent: state.cachedDraftSiteContent, updatedAt: state.publishState.draftUpdatedAt ?? new Date().toISOString() }
: await loadSiteContentFile(DRAFT_DATA_FILE)
const publishedAt = new Date().toISOString()
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
DATA_FILE,
JSON.stringify({ siteContent: source.siteContent, updatedAt: publishedAt }, null, 2),
'utf8',
)
state.cachedSiteContent = source.siteContent
state.publishState.publishedAt = publishedAt
if (state.draftQuestions !== null) {
state.questions = state.draftQuestions.slice(0, MAX_QUESTIONS)
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
QUESTIONS_FILE,
JSON.stringify({ questions: state.questions, updatedAt: publishedAt }, null, 2),
'utf8',
)
}
await createBackupSnapshot('post-publish')
res.json({ ok: true, publishedAt })
} catch (err) {
console.error('[admin-content-publish] persist error:', err)
const reason = err instanceof Error ? err.message : 'Unknown write error'
res.status(500).json({ message: `Failed to publish draft content to ${DATA_DIR}: ${reason}` })
}
})
app.put('/api/admin-content', requireAdminAuth, async (req, res) => {
try {
const { siteContent } = req.body ?? {}
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) {
res.status(400).json({ message: 'Invalid payload: siteContent must be an object.' })
return
}
const safeSiteContent = sanitizeSiteContent(siteContent)
const updatedAt = new Date().toISOString()
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
DATA_FILE,
JSON.stringify({ siteContent: safeSiteContent, updatedAt }, null, 2),
'utf8',
)
state.cachedSiteContent = safeSiteContent
state.publishState.publishedAt = updatedAt
res.json({ ok: true })
} catch {
res.status(500).json({ message: 'Failed to persist admin content.' })
}
})
app.get('/api/admin-ops/status', requireAdminAuth, (_req, res) => {
res.json({
buildCommit: process.env.BUILD_COMMIT ?? process.env.COMMIT_SHA ?? null,
buildNumber: process.env.BUILD_NUMBER ?? null,
deployedAt: process.env.DEPLOYED_AT ?? state.serverStartedAt,
cachePurge: state.lastCachePurgeStatus,
deployHook: state.lastDeployHookStatus,
})
})
app.post('/api/admin-ops/purge-cache', requireAdminAuth, async (_req, res) => {
const result = await invokeWebhook(process.env.CACHE_PURGE_WEBHOOK_URL ?? '', 'cache-purge')
state.lastCachePurgeStatus = { ok: result.ok, at: new Date().toISOString(), error: result.ok ? null : result.message }
if (!result.ok) {
res.status(400).json({ message: result.message })
return
}
res.json({ ok: true, message: result.message })
})
app.post('/api/admin-ops/deploy', requireAdminAuth, async (_req, res) => {
const result = await invokeWebhook(process.env.DEPLOY_WEBHOOK_URL ?? '', 'deploy')
state.lastDeployHookStatus = { ok: result.ok, at: new Date().toISOString(), error: result.ok ? null : result.message }
if (!result.ok) {
res.status(400).json({ message: result.message })
return
}
res.json({ ok: true, message: result.message })
})
app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => {
let adminContent = null
let draftContent = null
try {
const raw = await readFile(DATA_FILE, 'utf8')
adminContent = JSON.parse(raw)
} catch { adminContent = null }
try {
const rawDraft = await readFile(DRAFT_DATA_FILE, 'utf8')
draftContent = JSON.parse(rawDraft)
} catch { draftContent = null }
res.json({
exportedAt: new Date().toISOString(),
adminContent,
draftContent,
publishState: state.publishState,
hitStats: state.hitStats,
visitorStats: state.visitorStats,
contactSubmissions: state.contactSubmissions,
replyTemplates: state.replyTemplates,
replyHistory: state.replyHistory,
})
})
app.post('/api/admin-stats/clear', requireAdminAuth, (_req, res) => {
state.hitStats = { ...EMPTY_HIT_STATS }
state.visitorStats = { ...EMPTY_VISITOR_STATS }
queueHitStatsWrite()
queueVisitorStatsWrite()
createBackupSnapshot('post-clear').catch(() => {})
res.json({ ok: true })
})
app.post('/api/admin-stats/prune', requireAdminAuth, (req, res) => {
const result = pruneStatsByDays(req.body?.days)
queueHitStatsWrite()
queueVisitorStatsWrite()
createBackupSnapshot('post-prune').catch(() => {})
res.json({ ok: true, ...result })
})
app.post('/api/admin-stats/backup', requireAdminAuth, async (_req, res) => {
await createBackupSnapshot('manual')
res.json({ ok: true, backup: state.lastBackupStatus })
})
app.get('/api/admin-stats/backups', requireAdminAuth, async (_req, res) => {
try {
const backups = await listBackupPreviews()
res.json({ backups })
} catch {
res.status(500).json({ message: 'Could not list backups.' })
}
})
app.post('/api/admin-stats/backup-preview', requireAdminAuth, async (req, res) => {
try {
const { filename } = req.body ?? {}
const preview = await readBackupPreview(filename)
res.json({ preview })
} catch (err) {
res.status(400).json({ message: err instanceof Error ? err.message : 'Could not load backup preview.' })
}
})
app.post('/api/admin-stats/restore', requireAdminAuth, async (req, res) => {
try {
const { filename } = req.body ?? {}
await restoreFromBackup(filename)
const backups = await listBackupPreviews()
res.json({ ok: true, restored: filename, backups })
} catch (err) {
res.status(400).json({ message: err instanceof Error ? err.message : 'Restore failed.' })
}
})
// ── Webhooks ──────────────────────────────────────────────────────────────
app.get('/api/admin-webhooks', requireAdminAuth, (_req, res) => {
res.json({ webhooks: state.webhooks })
})
app.post('/api/admin-webhooks', requireAdminAuth, async (req, res) => {
const { url, events } = req.body ?? {}
if (!url || typeof url !== 'string' || !/^https:\/\//i.test(url.trim())) {
res.status(400).json({ message: 'A valid HTTPS URL is required.' })
return
}
try {
await assertPublicHttpsUrl(url.trim())
} catch (err) {
res.status(400).json({ message: `Webhook URL rejected: ${err.message}` })
return
}
if (state.webhooks.length >= 50) {
res.status(400).json({ message: 'Maximum of 50 webhooks reached.' })
return
}
const webhook = {
id: crypto.randomUUID(),
url: url.trim().slice(0, 500),
events: Array.isArray(events) ? events.map(String).slice(0, 20) : (typeof events === 'string' && events.trim() ? events.split(',').map(e => e.trim()).filter(Boolean) : ['*']),
createdAt: new Date().toISOString(),
}
state.webhooks.push(webhook)
queueWebhooksWrite()
res.status(201).json({ webhook })
})
app.delete('/api/admin-webhooks/:id', requireAdminAuth, (req, res) => {
const idx = state.webhooks.findIndex(w => w.id === req.params.id)
if (idx === -1) { res.status(404).json({ message: 'Not found.' }); return }
state.webhooks.splice(idx, 1)
queueWebhooksWrite()
res.json({ ok: true })
})
}
-255
View File
@@ -1,255 +0,0 @@
import { Resend } from 'resend'
import { readdir } from 'node:fs/promises'
import { requireAdminAuth } from '../auth.js'
import { buildSeasonCheck } from '../season-check.js'
import { countPendingUploads } from './nuggets-workflow.js'
import { state } from '../state.js'
import { getStorageStatus, loadUserProgress } from '../data.js'
import { getStudyCatalog } from '../study-helpers.js'
import { resendRetry } from '../email.js'
import { fetchAllEpisodes } from './episodes.js'
import { loadHistory } from './nuggets-host-upload.js'
import { BACKUP_DIR } from '../config.js'
const DAY_MS = 24 * 60 * 60 * 1000
const STALE_FEED_DAYS = 21
function dayKey(offsetDays) {
return new Date(Date.now() - offsetDays * DAY_MS).toISOString().slice(0, 10)
}
function countSince(items, getDate, fromMs, toMs = Infinity) {
let n = 0
for (const item of items) {
const t = new Date(getDate(item) ?? 0).getTime()
if (t >= fromMs && t < toMs) n++
}
return n
}
function dailySeries(items, getDate, days) {
const buckets = new Map()
for (let i = days - 1; i >= 0; i--) buckets.set(dayKey(i), 0)
for (const item of items) {
const key = String(getDate(item) ?? '').slice(0, 10)
if (buckets.has(key)) buckets.set(key, buckets.get(key) + 1)
}
return [...buckets.values()]
}
function topCounts(values, limit) {
const counts = {}
for (const v of values) if (v) counts[v] = (counts[v] ?? 0) + 1
return Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, limit)
}
function buildTrends() {
const now = Date.now()
const week = 7 * DAY_MS
const subscribers = state.contactSubmissions.filter(s => s.subscribe === true)
const studyUsers = state.studyUsers
const realByDay = state.hitStats.byDayReal ?? {}
const visitSeries = Array.from({ length: 14 }, (_, i) => realByDay[dayKey(13 - i)] ?? 0)
return {
visits: {
thisWeek: visitSeries.slice(7).reduce((a, b) => a + b, 0),
lastWeek: visitSeries.slice(0, 7).reduce((a, b) => a + b, 0),
series: visitSeries,
},
subscribers: {
thisWeek: countSince(subscribers, s => s.submittedAt, now - week),
lastWeek: countSince(subscribers, s => s.submittedAt, now - 2 * week, now - week),
series: dailySeries(subscribers, s => s.submittedAt, 14),
},
studySignups: {
thisWeek: countSince(studyUsers, u => u.createdAt, now - week),
lastWeek: countSince(studyUsers, u => u.createdAt, now - 2 * week, now - week),
series: dailySeries(studyUsers, u => u.createdAt, 14),
},
}
}
function buildTopTraffic() {
const cutoff = dayKey(6)
const rows = (state.visitorStats.recentVisits ?? []).filter(r => !r.isBot && r.at && r.at >= cutoff)
return {
topPages: topCounts(rows.map(r => r.path ?? '/'), 5).map(([path, hits]) => ({ path, hits })),
topReferrers: topCounts(rows.map(r => r.referrer), 5).map(([referrer, count]) => ({ referrer, count })),
}
}
function buildReengagement() {
const now = Date.now()
const tiers = { t1: 0, t2: 0, t3: 0 }
let recentlySent = 0
for (const user of state.studyUsers) {
const sent = user.reengagementSentAt && typeof user.reengagementSentAt === 'object' ? user.reengagementSentAt : {}
for (const at of Object.values(sent)) {
if (now - new Date(at).getTime() <= 30 * DAY_MS) recentlySent++
}
if (!user.studyRemindersEnabled || !user.lastLoginAt || !(user.enrolledStudySlugs?.length)) continue
const days = (now - new Date(user.lastLoginAt).getTime()) / DAY_MS
if (days >= 30) tiers.t3++
else if (days >= 14) tiers.t2++
else if (days >= 7) tiers.t1++
}
return { ...tiers, recentlySent, remindersEnabled: state.studyUsers.filter(u => u.studyRemindersEnabled).length }
}
async function buildStudyProgress() {
const catalog = getStudyCatalog()
const perStudy = new Map(catalog.map(s => [s.slug, { slug: s.slug, title: s.title, enrolled: 0, lessonsCompleted: 0, certificates: 0 }]))
let lastActivityAt = null
await Promise.all(state.studyUsers.map(async user => {
for (const slug of user.enrolledStudySlugs ?? []) {
const row = perStudy.get(slug)
if (row) row.enrolled++
}
try {
const progress = await loadUserProgress(user.id)
for (const [slug, data] of Object.entries(progress.byStudy ?? {})) {
const row = perStudy.get(slug)
if (row) row.lessonsCompleted += (data.completedSectionIds ?? []).length
}
if (progress.updatedAt && (!lastActivityAt || progress.updatedAt > lastActivityAt)) lastActivityAt = progress.updatedAt
} catch { /* ignore unreadable progress */ }
}))
for (const cert of state.studyCertificates ?? []) {
const row = perStudy.get(cert.studySlug)
if (row) row.certificates++
}
return {
studies: [...perStudy.values()].filter(s => s.enrolled > 0 || s.lessonsCompleted > 0 || s.certificates > 0),
certificatesTotal: (state.studyCertificates ?? []).length,
lastActivityAt,
}
}
async function buildLatestEpisode() {
try {
const episodes = await fetchAllEpisodes()
const latest = episodes[0]
if (!latest) return { available: false }
const published = new Date(latest.pubDate)
const valid = !isNaN(published.getTime())
const daysAgo = valid ? Math.floor((Date.now() - published.getTime()) / DAY_MS) : null
return {
available: true,
title: latest.title,
episode: latest.episode,
season: latest.season,
publishedAt: valid ? published.toISOString() : null,
daysAgo,
stale: daysAgo !== null && daysAgo > STALE_FEED_DAYS,
staleAfterDays: STALE_FEED_DAYS,
}
} catch {
return { available: false }
}
}
async function buildNuggets() {
const { entries } = await loadHistory()
const weekAgo = Date.now() - 7 * DAY_MS
return {
uploadsThisWeek: entries.filter(e => new Date(e.uploadedAt).getTime() >= weekAgo).length,
pending: await countPendingUploads(),
latest: entries.slice(0, 3).map(e => ({ hostName: e.hostName, episodeName: e.episodeName, uploadedAt: e.uploadedAt })),
entries: entries.slice(0, 10),
}
}
async function buildHealth() {
let backupCount = null
try { backupCount = (await readdir(BACKUP_DIR)).filter(f => f.endsWith('.json')).length } catch { /* no backup dir yet */ }
const storage = await getStorageStatus().catch(() => null)
return {
resendConfigured: Boolean(process.env.RESEND_API_KEY),
lastBackup: state.lastBackupStatus,
backupCount,
dataDirWritable: storage ? storage.writable?.ok === true : null,
writes: { hitStats: state.lastHitStatsWrite, visitorStats: state.lastVisitorStatsWrite },
serverStartedAt: state.serverStartedAt,
}
}
// The Resend SDK (6.x) exposes no open/click metrics for broadcasts, so this reports
// the most recent send and links to the Resend dashboard for engagement numbers.
async function buildLastBroadcast() {
if (!process.env.RESEND_API_KEY) return { available: false, reason: 'RESEND_API_KEY is not configured.' }
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const { data: list, error } = await resendRetry(() => resend.broadcasts.list())
if (error) throw new Error(error.message)
const sent = (list?.data ?? []).filter(b => b.status === 'sent')
.sort((a, b) => new Date(b.sent_at ?? 0).getTime() - new Date(a.sent_at ?? 0).getTime())[0]
if (!sent) return { available: true, broadcast: null }
const { data, error: getError } = await resendRetry(() => resend.broadcasts.get(sent.id))
if (getError || !data) throw new Error(getError?.message ?? 'Broadcast not found.')
return {
available: true,
broadcast: {
id: data.id,
name: data.name,
subject: data.subject,
sentAt: data.sent_at ?? sent.sent_at ?? null,
dashboardUrl: `https://resend.com/broadcasts/${data.id}`,
},
}
} catch (err) {
return { available: false, reason: err instanceof Error ? err.message : 'Could not reach Resend.' }
}
}
function buildActivity(nuggetEntries) {
const items = []
for (const c of state.contactSubmissions) items.push({ at: c.submittedAt, kind: 'contact', text: `${c.name || c.email} sent a contact message` })
for (const q of state.questions) items.push({ at: q.submittedAt, kind: 'question', text: `${q.firstName || 'Someone'} asked a question` })
for (const c of state.studyComments) items.push({ at: c.createdAt, kind: 'comment', text: `${c.displayName || 'A student'} commented on a study` })
for (const u of state.studyUsers) items.push({ at: u.createdAt, kind: 'study-user', text: `${u.displayName || u.username} created a study account` })
for (const c of state.studyCertificates ?? []) items.push({ at: c.issuedAt, kind: 'certificate', text: `${c.displayName} earned a certificate: ${c.studyTitle}` })
for (const e of nuggetEntries) items.push({ at: e.uploadedAt, kind: 'nuggets', text: `${e.hostName || 'A host'} uploaded "${e.episodeName}"` })
return items
.filter(i => i.at && !isNaN(new Date(i.at).getTime()))
.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime())
.slice(0, 12)
}
export function register(app) {
app.get('/api/admin-dashboard', requireAdminAuth, async (_req, res) => {
try {
const [studyProgress, latestEpisode, nuggets, health, lastBroadcast] = await Promise.all([
buildStudyProgress(), buildLatestEpisode(), buildNuggets(), buildHealth(), buildLastBroadcast(),
])
const comments = state.studyComments ?? []
const seasonCheck = await buildSeasonCheck()
.then(c => ({
total: c.total,
errors: c.errors,
warnings: c.warnings,
general: c.general.length,
lanes: c.lanes.map(l => ({ lane: l.lane, title: l.title, season: l.season, errors: l.errors, warnings: l.warnings })),
}))
.catch(() => null)
res.json({
seasonCheck,
trends: buildTrends(),
traffic: buildTopTraffic(),
reengagement: buildReengagement(),
studyProgress,
latestEpisode,
nuggets: { uploadsThisWeek: nuggets.uploadsThisWeek, pending: nuggets.pending, latest: nuggets.latest },
health,
lastBroadcast,
comments: {
pending: comments.filter(c => c.isApproved !== true).length,
thisWeek: countSince(comments, c => c.createdAt, Date.now() - 7 * DAY_MS),
},
activity: buildActivity(nuggets.entries),
})
} catch (err) {
console.error('[admin-dashboard] failed:', err)
res.status(500).json({ message: 'Failed to build dashboard data.' })
}
})
}
-496
View File
@@ -1,496 +0,0 @@
import { createHash, randomUUID } from 'node:crypto'
import rateLimit from 'express-rate-limit'
import { requireAdminAuth, isValidAdminSession } from '../auth.js'
import { getClientIp, hasVisitorConsent, setConsentCookie, parseCookies } from '../helpers.js'
import { getStudyUserFromRequest } from '../study-helpers.js'
import {
VISITOR_COOKIE,
MAX_RECENT_VISITS,
} from '../config.js'
import { state } from '../state.js'
import { queueVisitorStatsWrite, queueHitStatsWrite, normalizeMessageType, recordEpisodePlay, recordAnalyticsEvent } from '../data.js'
import {
detectBot,
sanitizeUserAgent,
detectDevice,
sanitizeReferrer,
normalizeHitPath,
isPrivateOrLocalIp,
buildTopLocations,
buildLastNDaysStats,
shouldCountHit,
recordHit,
isKnownSitePath,
getStudyCatalog,
} from '../study-helpers.js'
async function resolveGeo(ip, ipHash) {
if (!ip || isPrivateOrLocalIp(ip)) {
return { country: 'Local/Unknown', state: 'Local/Unknown', county: 'Local/Unknown', city: 'Local/Unknown' }
}
const cached = state.visitorStats.geoCacheByIp[ipHash]
if (cached) return cached
const providers = [
async () => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 2500)
try {
const response = await fetch(
`http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,country,regionName,city,district`,
{ signal: controller.signal },
)
if (!response.ok) return null
const data = await response.json()
if (data?.status !== 'success') return null
return { country: data?.country || 'Unknown', state: data?.regionName || 'Unknown', county: data?.district || 'Unknown', city: data?.city || 'Unknown' }
} finally { clearTimeout(timeout) }
},
async () => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 2500)
try {
const response = await fetch(`https://ipwho.is/${encodeURIComponent(ip)}`, { signal: controller.signal })
if (!response.ok) return null
const data = await response.json()
if (!data?.success) return null
return { country: data?.country || 'Unknown', state: data?.region || 'Unknown', county: data?.region || 'Unknown', city: data?.city || 'Unknown' }
} finally { clearTimeout(timeout) }
},
]
for (const provider of providers) {
try {
const geo = await provider()
if (geo) {
state.visitorStats.geoCacheByIp[ipHash] = geo
queueVisitorStatsWrite()
return geo
}
} catch { /* Try next provider */ }
}
const fallback = { country: 'Unknown', state: 'Unknown', county: 'Unknown', city: 'Unknown' }
state.visitorStats.geoCacheByIp[ipHash] = fallback
queueVisitorStatsWrite()
return fallback
}
// Simple in-memory rate limiter for pageview endpoint.
// This keys on req.ip, which collapses to one value for every visitor behind
// a shared IP (a proxy in front of the app, a school/church network, CGNAT).
// If TRUST_PROXY_HOPS is misconfigured for the real deployment topology,
// req.ip silently becomes the proxy's IP for everyone and this limiter starts
// dropping real traffic across all of them together. Log the first drop per
// IP per window so that scenario shows up instead of vanishing silently.
const pageviewHits = new Map()
function pageviewAllowed(ip) {
const now = Date.now()
const windowMs = 60 * 1000
const entry = pageviewHits.get(ip) ?? { count: 0, start: now, warned: false }
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now; entry.warned = false }
entry.count += 1
pageviewHits.set(ip, entry)
if (pageviewHits.size > 5000) {
const cutoff = now - windowMs
for (const [k, v] of pageviewHits) { if (v.start < cutoff) pageviewHits.delete(k) }
}
const allowed = entry.count <= 60
if (!allowed && !entry.warned) {
entry.warned = true
console.warn(`[analytics] pageview rate limit hit for an IP (${entry.count} requests/min) — if this fires often, check TRUST_PROXY_HOPS against the real deployment topology, since a misconfigured hop count collapses many visitors onto one IP here`)
}
return allowed
}
export function recordVisitor(req, res, overridePath = null, overrideReferrer = null) {
const cookies = parseCookies(req.headers.cookie)
let visitorId = cookies[VISITOR_COOKIE]
const hadCookie = Boolean(visitorId)
if (!visitorId) {
visitorId = randomUUID()
}
const nowIso = new Date().toISOString()
const pathKey = overridePath ? normalizeHitPath(overridePath) : normalizeHitPath(req.path)
const referrer = overrideReferrer !== null ? sanitizeReferrer(overrideReferrer) : sanitizeReferrer(req.get('referer') || req.get('referrer') || '')
const ip = getClientIp(req)
const ua = sanitizeUserAgent(req.get('user-agent'))
const device = detectDevice(ua)
const studyUser = getStudyUserFromRequest(req)
const studyIdentity = studyUser
? { userId: studyUser.id, username: studyUser.username, displayName: studyUser.displayName ?? studyUser.username }
: null
const ipHash = createHash('sha256').update(ip).digest('hex')
// Only fall back to IP-based correlation when the browser sent no cookie
// at all — e.g. it never persisted (Safari ITP, a cookie-blocking
// extension) — so that one real person doesn't get double-counted every
// visit. If a cookie WAS sent, trust it: two different people sharing a
// network (family wifi, a school or church LAN) must not be silently
// merged into one visitor just because they share an IP, which would
// corrupt page history and undercount unique visitors.
if (!hadCookie) {
const existingIdByIp = state.visitorStats.ipHashIndex[ipHash]
if (existingIdByIp) {
visitorId = existingIdByIp
}
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax; HttpOnly`)
}
const existingVisitor = state.visitorStats.visitors[visitorId]
const isReturning = Boolean(existingVisitor)
if (!existingVisitor) {
state.visitorStats.uniqueVisitors += 1
state.visitorStats.ipHashIndex[ipHash] = visitorId
} else {
state.visitorStats.returningVisits += 1
}
const nextVisitCount = (existingVisitor?.visitCount ?? 0) + 1
const userAgents = Array.from(new Set([...(existingVisitor?.userAgents ?? []), ua])).slice(0, 5)
const prevHistory = existingVisitor?.pageHistory ?? []
const pageHistory = [...prevHistory, { at: nowIso, path: pathKey, referrer }].slice(-100)
const knownIdentities = existingVisitor?.knownIdentities ?? []
if (studyIdentity && !knownIdentities.some(i => i.userId === studyIdentity.userId)) {
knownIdentities.push(studyIdentity)
}
// Use cached geo immediately; will be resolved async if not yet cached
const cachedGeo = state.visitorStats.geoCacheByIp[ipHash]
const geo = cachedGeo ?? { country: 'Unknown', state: 'Unknown', county: 'Unknown', city: 'Unknown' }
state.visitorStats.visitors[visitorId] = {
visitorId, ipHash,
firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso,
lastSeenAt: nowIso,
visitCount: nextVisitCount,
lastPath: pathKey,
returningVisitor: isReturning,
location: geo,
userAgents,
device,
pageHistory,
...(knownIdentities.length > 0 ? { knownIdentities } : {}),
}
state.visitorStats.totalVisits += 1
state.visitorStats.firstVisitAt = state.visitorStats.firstVisitAt ?? nowIso
state.visitorStats.lastVisitAt = nowIso
state.visitorStats.recentVisits.unshift({
at: nowIso, visitorId, ipHash,
path: pathKey, referrer, device,
country: geo.country, state: geo.state, county: geo.county, city: geo.city,
returningVisitor: isReturning, visitCount: nextVisitCount,
...(studyIdentity ? { studyUser: studyIdentity } : {}),
})
state.visitorStats.recentVisits = state.visitorStats.recentVisits.slice(0, MAX_RECENT_VISITS)
queueVisitorStatsWrite()
// Resolve geo in background if not already cached
if (!cachedGeo) {
resolveGeo(ip, ipHash).then(resolvedGeo => {
if (state.visitorStats.visitors[visitorId]) {
state.visitorStats.visitors[visitorId].location = resolvedGeo
}
const entry = state.visitorStats.recentVisits.find(v => v.visitorId === visitorId && v.at === nowIso)
if (entry) {
entry.country = resolvedGeo.country
entry.state = resolvedGeo.state
entry.county = resolvedGeo.county
entry.city = resolvedGeo.city
}
queueVisitorStatsWrite()
}).catch(() => {})
}
}
const analyticsWriteLimiter = rateLimit({
windowMs: 60 * 1000,
max: 120,
standardHeaders: true,
legacyHeaders: false,
message: { ok: false, reason: 'rate-limited' },
})
export function register(app) {
app.use(['/api/analytics/play', '/api/analytics/event'], analyticsWriteLimiter)
app.post('/api/analytics-consent', (req, res) => {
const consent = req.body?.consent === true
setConsentCookie(res, consent)
res.json({ ok: true, consent })
})
app.post('/api/analytics/play', (req, res) => {
if (isValidAdminSession(req)) { res.json({ ok: false, reason: 'admin' }); return }
if (!hasVisitorConsent(req)) { res.json({ ok: false, reason: 'no-consent' }); return }
const ua = req.get('user-agent') ?? ''
const { isBot } = detectBot(ua)
if (isBot) { res.json({ ok: false, reason: 'bot' }); return }
const title = typeof req.body?.title === 'string' ? req.body.title.trim().slice(0, 200) : ''
if (!title) { res.status(400).json({ ok: false, reason: 'missing-title' }); return }
recordEpisodePlay(title)
res.json({ ok: true })
})
app.post('/api/analytics/event', (req, res) => {
if (isValidAdminSession(req)) { res.json({ ok: false, reason: 'admin' }); return }
if (!hasVisitorConsent(req)) { res.json({ ok: false, reason: 'no-consent' }); return }
const ua = req.get('user-agent') ?? ''
const { isBot } = detectBot(ua)
if (isBot) { res.json({ ok: false, reason: 'bot' }); return }
const type = typeof req.body?.type === 'string' ? req.body.type : ''
const ALLOWED_TYPES = ['scroll_depth', 'time_on_page', 'outbound_click', 'link_click', 'utm', 'search_query', 'not_found', 'audio_pause', 'audio_completion', 'audio_listen_time']
if (!ALLOWED_TYPES.includes(type)) { res.status(400).json({ ok: false, reason: 'invalid-type' }); return }
recordAnalyticsEvent(type, req.body)
res.json({ ok: true })
})
app.post('/api/analytics/pageview', (req, res) => {
if (isValidAdminSession(req)) { res.json({ ok: false, reason: 'admin' }); return }
if (!hasVisitorConsent(req)) { res.json({ ok: false, reason: 'no-consent' }); return }
const ua = req.get('user-agent') ?? ''
const { isBot } = detectBot(ua)
if (isBot) { res.json({ ok: false, reason: 'bot' }); return }
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()
recordVisitor(req, res, rawPath, rawReferrer)
res.json({ ok: true })
})
app.get('/api/admin-stats', requireAdminAuth, (req, res) => {
const rangeParam = req.query.range
const isAllTime = rangeParam === 'all'
const rangeDays = rangeParam === '7d' ? 7 : rangeParam === '90d' ? 90 : isAllTime ? null : 30
const rangeLabel = rangeParam === '7d' ? 'Last 7 days' : rangeParam === '90d' ? 'Last 90 days' : isAllTime ? 'All time' : 'Last 30 days'
// Cutoff date (null = all-time)
const cutoffDate = isAllTime ? null : (() => {
const d = new Date()
d.setDate(d.getDate() - ((rangeDays ?? 30) - 1))
return d.toISOString().slice(0, 10)
})()
// All known day keys, sorted
const allDayKeys = new Set([
...Object.keys(state.hitStats.byDayReal ?? {}),
...Object.keys(state.hitStats.byDayBot ?? {}),
])
const filteredDayKeys = isAllTime
? [...allDayKeys].sort()
: [...allDayKeys].filter(day => day >= cutoffDate).sort()
// Aggregate hits for the range
const rangeRealHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayReal?.[day] ?? 0), 0)
const rangeBotHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayBot?.[day] ?? 0), 0)
const rangeTotalHits = rangeRealHits + rangeBotHits
// Trend chart data — one point per day for the selected range
const trendData = filteredDayKeys.map(day => ({
day,
real: state.hitStats.byDayReal?.[day] ?? 0,
bot: state.hitStats.byDayBot?.[day] ?? 0,
}))
// Visitor rows for range
const rangeVisitorRows = isAllTime
? state.visitorStats.recentVisits
: state.visitorStats.recentVisits.filter(row => {
if (!row.at) return false
return row.at >= cutoffDate
})
const rangePathCountsReal = {}
const rangePathCountsBot = {}
for (const row of rangeVisitorRows) {
const p = row.path ?? '/'
if (row.isBot) rangePathCountsBot[p] = (rangePathCountsBot[p] ?? 0) + 1
else rangePathCountsReal[p] = (rangePathCountsReal[p] ?? 0) + 1
}
const topPathsReal = Object.entries(rangePathCountsReal).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([path, hits]) => ({ path, hits }))
const topPathsBot = Object.entries(rangePathCountsBot).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([path, hits]) => ({ path, hits }))
const topPaths = [...topPathsReal, ...topPathsBot].reduce((acc, { path, hits }) => {
const existing = acc.find(x => x.path === path)
if (existing) existing.hits += hits; else acc.push({ path, hits })
return acc
}, []).sort((a, b) => b.hits - a.hits).slice(0, 10)
const last7Days = buildLastNDaysStats(7)
const last7DaysReal = last7Days.map(item => ({ day: item.day, hits: state.hitStats.byDayReal?.[item.day] ?? 0 }))
const last7DaysBot = last7Days.map(item => ({ day: item.day, hits: state.hitStats.byDayBot?.[item.day] ?? 0 }))
const last30Days = buildLastNDaysStats(30)
const last30DaysTotal = last30Days.reduce((sum, item) => sum + item.hits, 0)
const last30DaysRealTotal = last30Days.reduce((sum, item) => sum + (state.hitStats.byDayReal?.[item.day] ?? 0), 0)
const last30DaysBotTotal = last30Days.reduce((sum, item) => sum + (state.hitStats.byDayBot?.[item.day] ?? 0), 0)
const botReasons = Object.entries(state.hitStats.botReasons ?? {})
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([reason, count]) => ({ reason, count }))
// Capped to 100 for the visitor log display (with page history hydrated)
const recentVisitorRows = rangeVisitorRows.slice(0, 100).map(row => {
const fullVisitor = state.visitorStats.visitors[row.visitorId]
return { ...row, pageHistory: fullVisitor?.pageHistory ?? [] }
})
const enrollmentCountsBySlug = {}
for (const user of state.studyUsers) {
const userEnrollments = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : []
for (const studySlug of userEnrollments) {
enrollmentCountsBySlug[studySlug] = (enrollmentCountsBySlug[studySlug] ?? 0) + 1
}
}
const enrollmentsByStudy = getStudyCatalog()
.map(study => ({ slug: study.slug, title: study.title, count: enrollmentCountsBySlug[study.slug] ?? 0 }))
.sort((a, b) => b.count - a.count)
const studyCatalogBySlug = new Map(getStudyCatalog().map(study => [study.slug, study]))
const users = state.studyUsers
.map(user => {
const enrolledStudySlugs = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : []
const enrolledStudies = enrolledStudySlugs.map(slug => {
const study = studyCatalogBySlug.get(slug)
return study ? { slug: study.slug, title: study.title } : null
}).filter(Boolean)
return { id: user.id, username: user.username, displayName: user.displayName ?? '', enrolledStudies }
})
.sort((a, b) => {
if (b.enrolledStudies.length !== a.enrolledStudies.length) return b.enrolledStudies.length - a.enrolledStudies.length
return a.username.localeCompare(b.username)
})
const enrolledUsers = state.studyUsers.filter(user => (user.enrolledStudySlugs?.length ?? 0) > 0).length
const totalEnrollments = Object.values(enrollmentCountsBySlug).reduce((sum, count) => sum + count, 0)
const funnelSignups = state.studyUsers.length
const funnelFirstVisit = state.studyUsers.filter(u => u.firstVisitAt).length
const funnelFirstCompletion = state.studyUsers.filter(u => u.firstCompletionAt).length
res.json({
totalHits: state.hitStats.totalHits,
realHits: rangeRealHits,
botHits: rangeBotHits,
rangeTotalHits,
rangeLabel,
rangeDays,
isAllTime,
trendData,
// All-time totals for reference
allTimeRealHits: state.hitStats.realHits ?? 0,
allTimeBotHits: state.hitStats.botHits ?? 0,
allTimeTotalHits: state.hitStats.totalHits,
firstHitAt: state.hitStats.firstHitAt,
lastHitAt: state.hitStats.lastHitAt,
topPaths, topPathsReal, topPathsBot,
topUnknownPaths: Object.entries(state.hitStats.byPathUnknown ?? {})
.sort((a, b) => b[1] - a[1]).slice(0, 25).map(([path, hits]) => ({ path, hits })),
uniqueVisitors7d: new Set(
state.visitorStats.recentVisits
.filter(v => Date.now() - new Date(v.at).getTime() <= 7 * 24 * 60 * 60 * 1000)
.map(v => v.visitorId),
).size,
last7Days, last7DaysReal, last7DaysBot,
last30DaysTotal, last30DaysRealTotal, last30DaysBotTotal,
botReasons,
visitors: {
totalVisits: state.visitorStats.totalVisits,
uniqueVisitors: state.visitorStats.uniqueVisitors,
returningVisits: state.visitorStats.returningVisits,
firstVisitAt: state.visitorStats.firstVisitAt,
lastVisitAt: state.visitorStats.lastVisitAt,
topCountries: buildTopLocations(rangeVisitorRows, 'country'),
topStates: buildTopLocations(rangeVisitorRows, 'state'),
topCounties: buildTopLocations(rangeVisitorRows, 'county'),
topCities: buildTopLocations(rangeVisitorRows, 'city'),
deviceBreakdown: (() => {
const counts = { mobile: 0, desktop: 0, tablet: 0, unknown: 0 }
for (const row of rangeVisitorRows) {
const d = row.device ?? 'unknown'
counts[d] = (counts[d] ?? 0) + 1
}
return counts
})(),
topReferrers: (() => {
const counts = {}
for (const row of rangeVisitorRows) {
if (!row.referrer) continue
counts[row.referrer] = (counts[row.referrer] ?? 0) + 1
}
return Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([referrer, count]) => ({ referrer, count }))
})(),
last30DaysReal: buildLastNDaysStats(30).map(item => ({ day: item.day, hits: state.hitStats.byDayReal?.[item.day] ?? 0 })),
recentVisits: recentVisitorRows,
},
writeStatus: {
hitStats: state.lastHitStatsWrite,
visitorStats: state.lastVisitorStatsWrite,
backups: state.lastBackupStatus,
cachePurge: state.lastCachePurgeStatus,
deployHook: state.lastDeployHookStatus,
},
contactTotals: {
totalSubmissions: state.contactSubmissions.length,
totalQuestions: state.contactSubmissions.filter(entry => normalizeMessageType(entry?.messageType) === 'question').length,
},
studyEnrollment: {
totalUsers: state.studyUsers.length,
enrolledUsers,
totalEnrollments,
enrollmentsByStudy,
users,
funnel: { signups: funnelSignups, firstVisit: funnelFirstVisit, firstCompletion: funnelFirstCompletion },
},
episodePlays: Object.entries(state.episodePlays)
.map(([title, data]) => ({
title,
total: data.total ?? 0,
byDay: data.byDay ?? {},
last30Days: buildLastNDaysStats(30).map(item => ({ day: item.day, plays: data.byDay?.[item.day] ?? 0 })),
}))
.sort((a, b) => b.total - a.total),
engagement: {
scrollDepth: Object.entries(state.analyticsEvents.scrollDepth ?? {})
.map(([path, marks]) => ({ path, ...marks }))
.sort((a, b) => (b[90] ?? 0) - (a[90] ?? 0))
.slice(0, 20),
timeOnPage: Object.entries(state.analyticsEvents.timeOnPage ?? {})
.map(([path, { totalSeconds, count }]) => ({ path, avgSeconds: count > 0 ? Math.round(totalSeconds / count) : 0, count }))
.sort((a, b) => b.avgSeconds - a.avgSeconds)
.slice(0, 20),
topOutboundClicks: Object.entries(state.analyticsEvents.outboundClicks ?? {})
.map(([url, count]) => ({ url, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 20),
topLinkClicks: Object.entries(state.analyticsEvents.linkClicks ?? {})
.map(([url, { count, internal }]) => ({ url, count, internal }))
.sort((a, b) => b.count - a.count)
.slice(0, 50),
topUTMSources: Object.entries(state.analyticsEvents.utmSources ?? {})
.map(([source, count]) => ({ source, count }))
.sort((a, b) => b.count - a.count),
topSearchQueries: Object.entries(state.analyticsEvents.searchQueries ?? {})
.map(([query, count]) => ({ query, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 30),
top404s: Object.entries(state.analyticsEvents.notFound ?? {})
.map(([path, count]) => ({ path, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 20),
audioEvents: Object.entries(state.analyticsEvents.audioEvents ?? {})
.map(([title, data]) => ({ title, ...data }))
.sort((a, b) => b.completions - a.completions),
},
})
})
}
-104
View File
@@ -1,104 +0,0 @@
import rateLimit from 'express-rate-limit'
import { BIBLE_BOOKS } from '../../shared/bible-books.js'
const passageCache = new Map()
const PASSAGE_CACHE_MAX = 2000
const MAX_CHAPTER = 150
const MAX_VERSE = 176
const MAX_VERSES_PER_REQUEST = 60
const passageRateLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many passage requests. Please slow down.' },
})
// Keyed by lowercase, whitespace-stripped book name/alias (matching the format
// the admin's "Bible Book" field and the /api/bible-passage `book` query param
// both use), mapped to bolls.life's numeric book id (canonical order, 1-66).
// Derived from the shared BIBLE_BOOKS list so it can't drift from the client's
// Scripture Index book map (src/App.tsx's SI_BOOK_MAP).
const BOLLS_BOOK_MAP = {}
BIBLE_BOOKS.forEach((b, index) => {
const bookNum = index + 1
for (const key of [b.name.toLowerCase(), ...(b.aliases ?? [])]) {
BOLLS_BOOK_MAP[key.replace(/\s+/g, '')] = bookNum
}
})
function parseReference(reference) {
// 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 {
chapter: parseInt(match[1], 10),
verseStart: parseInt(match[2], 10),
verseEnd: match[3] ? parseInt(match[3], 10) : parseInt(match[2], 10),
}
}
export function register(app) {
app.get('/api/bible-passage', passageRateLimiter, async (req, res) => {
const book = String(req.query.book ?? '').trim().toLowerCase().replace(/[\s-]/g, '')
const reference = String(req.query.reference ?? '').trim()
const bookNum = BOLLS_BOOK_MAP[book]
if (!bookNum) {
res.status(400).json({ message: 'Unknown book.' })
return
}
const parsed = parseReference(reference)
if (!parsed) {
res.status(400).json({ message: 'Invalid reference format. Expected format: chapter:verseStart-verseEnd' })
return
}
if (
parsed.chapter < 1 || parsed.chapter > MAX_CHAPTER
|| parsed.verseStart < 1 || parsed.verseEnd > MAX_VERSE
|| parsed.verseEnd < parsed.verseStart
|| parsed.verseEnd - parsed.verseStart + 1 > MAX_VERSES_PER_REQUEST
) {
res.status(400).json({ message: `Passage out of range (max ${MAX_VERSES_PER_REQUEST} verses).` })
return
}
const cacheKey = `BSB:${bookNum}:${parsed.chapter}:${parsed.verseStart}:${parsed.verseEnd}`
if (passageCache.has(cacheKey)) {
res.json({ text: passageCache.get(cacheKey) })
return
}
try {
const verseNums = []
for (let v = parsed.verseStart; v <= parsed.verseEnd; v++) verseNums.push(v)
const results = await Promise.all(
verseNums.map(v =>
fetch(`https://bolls.life/get-verse/BSB/${bookNum}/${parsed.chapter}/${v}/`, { signal: AbortSignal.timeout(8000) })
.then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)))
)
)
if (results.length === 0) {
res.status(404).json({ message: 'Passage not found.' })
return
}
const text = results.map(v => `v. ${v.verse} ${String(v.text).trim()}`).join('\n\n')
if (passageCache.size >= PASSAGE_CACHE_MAX) passageCache.delete(passageCache.keys().next().value)
passageCache.set(cacheKey, text)
res.json({ text })
} catch {
res.status(502).json({ message: 'Unable to fetch passage right now.' })
}
})
}
-796
View File
@@ -1,796 +0,0 @@
import { randomUUID, timingSafeEqual } from 'node:crypto'
import { Resend } from 'resend'
import { requireAdminAuth } from '../auth.js'
import { escapeHtml, splitName, buildAbsoluteUrl } from '../helpers.js'
import {
MAX_CONTACT_SUBMISSIONS,
MAX_QUESTIONS,
USE_RESEND_AUTOMATION_WELCOME,
DEFAULT_SEO,
DATA_FILE,
} from '../config.js'
import { state } from '../state.js'
import {
queueContactSubmissionsWrite,
queueQuestionsWrite,
queueDraftQuestionsWrite,
normalizeContactEmailStatus,
normalizeMessageType,
fireWebhooks,
loadSiteContentFile,
} from '../data.js'
import {
noteContactEmailCooldown,
extractTagValue,
mapResendEventToStatus,
extractResendMessageId,
} from '../study-helpers.js'
import {
getResendFromAddress,
getResendReplyToAddress,
getResendInboxAddress,
buildContactWelcomeEmailTemplate,
buildContactAdminNotificationTemplate,
sendResendEmailWithRetry,
syncContactToResend,
resendRetry,
} from '../email.js'
// RFC 5322 msg-id: "<" printable-ASCII-no-whitespace ">"
const MESSAGE_ID_RE = /^<[\x21-\x7E]+>$/
function upsertContactEmailStatus(submissionId, stream, patch) {
if (!submissionId || typeof submissionId !== 'string') return
if (!stream || typeof stream !== 'string') return
const at = typeof patch?.lastEventAt === 'string' ? patch.lastEventAt : new Date().toISOString()
let updated = false
state.contactSubmissions = state.contactSubmissions.map(submission => {
if (submission.id !== submissionId) return submission
const next = normalizeContactEmailStatus(submission.emailStatus, submission.subscribe === true)
const current = next[stream] ?? { status: 'pending', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null }
next[stream] = { ...current, ...patch, lastEventAt: at }
updated = true
return { ...submission, emailStatus: next }
})
if (updated) queueContactSubmissionsWrite()
}
function registerResendMessageForSubmission(submissionId, stream, sendResult) {
const resendMessageId = extractResendMessageId(sendResult)
if (!resendMessageId || !submissionId || !stream) return
state.resendEmailSubmissionIndex.set(resendMessageId, { submissionId, stream })
// Trim the index when it grows large; oldest entries are least likely to receive webhooks
if (state.resendEmailSubmissionIndex.size > 2000) {
const firstKey = state.resendEmailSubmissionIndex.keys().next().value
state.resendEmailSubmissionIndex.delete(firstKey)
}
upsertContactEmailStatus(submissionId, stream, { resendEmailId: resendMessageId })
}
function shouldSendWelcomeEmail({ subscribe }) {
return subscribe === true
}
const contactHits = new Map()
function contactRateLimit(req, res, next) {
const ip = req.ip ?? 'unknown'
const now = Date.now()
const windowMs = 10 * 60 * 1000
const entry = contactHits.get(ip) ?? { count: 0, start: now }
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now }
entry.count += 1
contactHits.set(ip, entry)
// Prune stale entries to prevent unbounded growth
if (contactHits.size > 5000) {
const cutoff = now - windowMs
for (const [k, v] of contactHits) { if (v.start < cutoff) contactHits.delete(k) }
}
if (entry.count > 5) {
res.status(429).json({ message: 'Too many messages. Please wait a few minutes.' })
return
}
next()
}
export function register(app) {
app.post('/api/contact', contactRateLimit, async (req, res) => {
try {
const { firstName, lastName, email, message, messageType, subscribe, notifyOnAnswer, _honey } = req.body ?? {}
if (_honey) { res.json({ ok: true }); return }
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
res.status(400).json({ message: 'First name is required.' }); return
}
if (lastName !== undefined && lastName !== null && (typeof lastName !== 'string' || lastName.trim().length > 100)) {
res.status(400).json({ message: 'Last name is too long.' }); return
}
if (!email || typeof email !== 'string' || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email.trim())) {
res.status(400).json({ message: 'A valid email address is required.' }); return
}
if (!message || typeof message !== 'string' || message.trim().length < 5 || message.trim().length > 3000) {
res.status(400).json({ message: 'Message must be between 5 and 3000 characters.' }); return
}
if (!process.env.RESEND_API_KEY) {
console.error('[contact] RESEND_API_KEY env var not set')
res.status(503).json({ message: 'The contact form is not yet configured on the server.' }); return
}
const trimmedName = [firstName.trim(), typeof lastName === 'string' ? lastName.trim() : ''].filter(Boolean).join(' ')
const trimmedEmail = email.trim()
const trimmedMessage = message.trim()
const normalizedMessageType = normalizeMessageType(messageType)
const cooldown = noteContactEmailCooldown(trimmedEmail)
if (!cooldown.ok) {
const retryAfterSeconds = Math.max(1, Math.ceil(cooldown.retryAfterMs / 1000))
res.status(429).json({ message: `Please wait ${retryAfterSeconds}s before sending another message from this email.` }); return
}
const submittedAt = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })
const shouldSendWelcome = shouldSendWelcomeEmail({ subscribe })
const submission = {
id: randomUUID(),
submittedAt: new Date().toISOString(),
name: trimmedName,
email: trimmedEmail,
message: trimmedMessage,
messageType: normalizedMessageType,
subscribe: shouldSendWelcome,
archived: false,
emailStatus: normalizeContactEmailStatus(null, shouldSendWelcome),
}
state.contactSubmissions.unshift(submission)
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
queueContactSubmissionsWrite()
fireWebhooks('contact.new', { name: trimmedName, email: trimmedEmail, message: trimmedMessage, messageType: normalizedMessageType, submittedAt: submission.submittedAt })
if (normalizedMessageType === 'question') {
const question = {
id: randomUUID(),
submittedAt: new Date().toISOString(),
firstName: splitName(trimmedName).firstName,
email: trimmedEmail,
question: trimmedMessage,
answer: '',
answeredAt: null,
isApproved: false,
approvedAt: null,
notifyOnAnswer: notifyOnAnswer === true,
}
state.questions.unshift(question)
state.questions = state.questions.slice(0, MAX_QUESTIONS)
if (state.draftQuestions !== null) {
state.draftQuestions.unshift(question)
state.draftQuestions = state.draftQuestions.slice(0, MAX_QUESTIONS)
queueDraftQuestionsWrite()
}
queueQuestionsWrite()
}
const resend = new Resend(process.env.RESEND_API_KEY)
const adminInbox = getResendInboxAddress()
const replyToAddress = getResendReplyToAddress()
const fromAddress = getResendFromAddress()
const safeMessageTypeTag = normalizedMessageType.replace(/[^a-z0-9_-]/gi, '-').toLowerCase()
const adminTemplate = buildContactAdminNotificationTemplate({ normalizedMessageType, trimmedName, trimmedEmail, submittedAt, trimmedMessage })
let welcomeSent = false
if (subscribe === true) {
await syncContactToResend(trimmedName, trimmedEmail)
}
if (shouldSendWelcome && !USE_RESEND_AUTOMATION_WELCOME) {
const greetingName = splitName(trimmedName).firstName?.trim() ?? ''
let publishedSiteContent = state.cachedSiteContent
if (!publishedSiteContent) {
try {
const published = await loadSiteContentFile(DATA_FILE)
publishedSiteContent = published?.siteContent ?? null
} catch { publishedSiteContent = null }
}
const emailConfig = publishedSiteContent ?? {}
const welcomeBaseUrl = typeof emailConfig?.seo?.canonicalUrl === 'string' && emailConfig.seo.canonicalUrl.trim()
? emailConfig.seo.canonicalUrl.trim()
: DEFAULT_SEO.canonicalUrl
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 welcomeTemplate = buildContactWelcomeEmailTemplate({
greetingName,
welcomeGreetingPrefix,
welcomeIntro: emailConfig.welcomeEmailIntro?.trim() || 'Thanks for subscribing to Verse by Verse with Nate - a Bible teaching podcast where we slow down, dig into the text, and pull out the nuggets God has for us word by word.',
welcomeCurrentSeries: emailConfig.welcomeEmailCurrentSeries?.trim() || "Right now we're working through the book of Titus - a short letter packed with practical wisdom about grace, godliness, and what the Christian life looks like when it's rooted in sound doctrine.",
welcomeStartHereTitle: emailConfig.welcomeEmailStartHereTitle?.trim() || 'Episode 1 - Introduction to Titus',
welcomeStartHereSummary: emailConfig.welcomeEmailStartHereSummary?.trim() || 'Who wrote it, who received it, and why it still matters.',
welcomeExpect1: emailConfig.welcomeEmailWhatToExpect1?.trim() || 'Verse-by-verse teaching - we go slow and let the text speak for itself.',
welcomeExpect2: emailConfig.welcomeEmailWhatToExpect2?.trim() || 'Greek word studies - the kind that open up meaning without being a lecture.',
welcomeExpect3: emailConfig.welcomeEmailWhatToExpect3?.trim() || 'New episodes + study notes delivered right to your inbox.',
welcomeScripture: emailConfig.welcomeEmailScripture?.trim() || 'For the grace of God has appeared, bringing salvation to all people.',
welcomeScriptureRef: emailConfig.welcomeEmailScriptureRef?.trim() || 'Titus 2:11 - BSB',
welcomeSignoff: emailConfig.welcomeEmailSignoff?.trim() || 'Grace and peace,\nNate',
welcomeSpotifyUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_SPOTIFY_URL ?? emailConfig.welcomeEmailSpotifyUrl ?? '/spotify'),
welcomeAppleUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_APPLE_URL ?? emailConfig.welcomeEmailAppleUrl ?? '/apple'),
welcomeAmazonUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_AMAZON_URL ?? emailConfig.welcomeEmailAmazonUrl ?? '/amazon'),
welcomeWebsiteUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_WEBSITE_URL ?? emailConfig.welcomeEmailWebsiteUrl ?? '/'),
welcomeEpisodeUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_EPISODE_URL ?? emailConfig.welcomeEmailStartHereUrl ?? '/start-here'),
welcomeImageUrl: buildAbsoluteUrl(welcomeBaseUrl, process.env.RESEND_WELCOME_IMAGE_URL ?? emailConfig.welcomeEmailImageUrl ?? '/images/podcast-art.jpeg'),
welcomeSpotifyBtnLabel: emailConfig.welcomeEmailSpotifyBtnLabel?.trim() || 'Listen on Spotify',
welcomeAppleBtnLabel: emailConfig.welcomeEmailAppleBtnLabel?.trim() || 'Apple Podcasts',
welcomeStartHereLinkLabel: emailConfig.welcomeEmailStartHereLinkLabel?.trim() || 'Open Start Here page',
})
try {
const welcomeSendResult = await sendResendEmailWithRetry({
resend,
context: 'contact-welcome',
payload: {
from: fromAddress,
to: [trimmedEmail],
replyTo: replyToAddress,
subject: welcomeSubject,
tags: [
{ name: 'flow', value: 'contact-welcome' },
{ name: 'message_type', value: safeMessageTypeTag },
{ name: 'submission_id', value: submission.id },
],
headers: {
'List-Unsubscribe': `<mailto:${replyToAddress}?subject=Unsubscribe>`,
'X-Contact-Submission-Id': submission.id,
},
text: welcomeTemplate.text,
html: welcomeTemplate.html,
},
})
registerResendMessageForSubmission(submission.id, 'welcome', welcomeSendResult)
upsertContactEmailStatus(submission.id, 'welcome', { status: 'sent', lastEventType: 'email.sent', error: null })
welcomeSent = true
} catch (welcomeErr) {
upsertContactEmailStatus(submission.id, 'welcome', {
status: 'failed',
lastEventType: 'email.failed',
error: String(welcomeErr?.message ?? welcomeErr ?? 'unknown error').slice(0, 600),
})
console.error('[contact] welcome email failed:', welcomeErr)
// Submission is already saved — don't 500 the user; fall through to admin notification.
}
} else if (shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME) {
upsertContactEmailStatus(submission.id, 'welcome', { status: 'automation-enabled', lastEventType: 'email.automation.enabled', error: null })
}
try {
const adminSendResult = await sendResendEmailWithRetry({
resend,
context: 'contact-admin-notification',
payload: {
from: fromAddress,
to: [adminInbox],
replyTo: trimmedEmail,
subject: adminTemplate.subject,
tags: [
{ name: 'flow', value: 'contact-admin' },
{ name: 'message_type', value: safeMessageTypeTag },
{ name: 'submission_id', value: submission.id },
],
headers: { 'X-Contact-Submission-Id': submission.id },
text: adminTemplate.text,
html: adminTemplate.html,
},
})
registerResendMessageForSubmission(submission.id, 'adminNotification', adminSendResult)
upsertContactEmailStatus(submission.id, 'adminNotification', { status: 'sent', lastEventType: 'email.sent', error: null })
} catch (adminSendErr) {
upsertContactEmailStatus(submission.id, 'adminNotification', {
status: 'failed',
lastEventType: 'email.failed',
error: String(adminSendErr?.message ?? adminSendErr ?? 'unknown error').slice(0, 600),
})
console.error('[contact] admin notification email failed:', adminSendErr)
// Submission is already saved — don't 500 the user.
}
res.json({ ok: true, welcomeSent, welcomeHandledByAutomation: shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME })
} catch (err) {
console.error('[contact] send error:', err)
res.status(500).json({ message: 'Failed to send your message. Please try again or email us directly.' })
}
})
app.post('/api/resend/webhook', (req, res) => {
const expectedToken = typeof process.env.RESEND_WEBHOOK_TOKEN === 'string' ? process.env.RESEND_WEBHOOK_TOKEN.trim() : ''
if (!expectedToken) {
res.status(503).json({ message: 'Webhook token is not configured.' }); return
}
const providedToken = (req.get('x-webhook-token') || '').trim()
|| (req.get('x-resend-webhook-token') || '').trim()
|| String(req.query?.token || '').trim()
|| (req.get('authorization') || '').replace(/^Bearer\s+/i, '').trim()
const tokenA = Buffer.from(providedToken, 'utf8')
const tokenB = Buffer.from(expectedToken, 'utf8')
if (!providedToken || tokenA.length !== tokenB.length || !timingSafeEqual(tokenA, tokenB)) {
res.status(401).json({ message: 'Unauthorized webhook.' }); return
}
const body = req.body && typeof req.body === 'object' ? req.body : {}
const eventType = typeof body.type === 'string' ? body.type.trim() : ''
const data = body.data && typeof body.data === 'object' ? body.data : {}
const tags = Array.isArray(data.tags) ? data.tags : []
const resendMessageId = (
typeof data.email_id === 'string' && data.email_id.trim()
? data.email_id.trim()
: (typeof data.emailId === 'string' && data.emailId.trim()
? data.emailId.trim()
: (typeof data.id === 'string' && data.id.trim() ? data.id.trim() : ''))
)
const indexed = resendMessageId ? state.resendEmailSubmissionIndex.get(resendMessageId) : null
const taggedSubmissionId = extractTagValue(tags, 'submission_id')
const submissionId = indexed?.submissionId || taggedSubmissionId
const flow = extractTagValue(tags, 'flow')
const stream = indexed?.stream
|| (flow === 'contact-welcome' ? 'welcome' : '')
|| (flow === 'contact-admin' ? 'adminNotification' : '')
|| (flow === 'admin-reply' ? 'adminReply' : '')
if (!submissionId || !stream) {
res.json({ ok: true, ignored: true }); return
}
upsertContactEmailStatus(submissionId, stream, {
status: mapResendEventToStatus(eventType),
lastEventType: eventType || 'webhook.event',
resendEmailId: resendMessageId || null,
error: typeof data?.message === 'string' ? data.message.slice(0, 600) : null,
})
res.json({ ok: true })
})
app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => {
res.json({ submissions: state.contactSubmissions })
})
app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
const { archived, read } = req.body ?? {}
if (typeof archived !== 'boolean' && typeof read !== 'boolean') {
res.status(400).json({ message: 'archived or read must be a boolean.' }); return
}
const idx = state.contactSubmissions.findIndex(s => s.id === id)
if (idx === -1) {
res.status(404).json({ message: 'Submission not found.' }); return
}
const patch = {}
if (typeof archived === 'boolean') patch.archived = archived
if (typeof read === 'boolean') patch.read = read
state.contactSubmissions[idx] = { ...state.contactSubmissions[idx], ...patch }
queueContactSubmissionsWrite()
res.json({ ok: true, submission: state.contactSubmissions[idx] })
})
app.post('/api/admin-contact-submissions/bulk', requireAdminAuth, (req, res) => {
const { ids, action } = req.body ?? {}
const actions = ['archive', 'unarchive', 'read', 'unread', 'delete']
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 500 || !ids.every(id => typeof id === 'string') || !actions.includes(action)) {
res.status(400).json({ message: 'Provide ids (1-500) and a valid action.' }); return
}
const idSet = new Set(ids)
let count = 0
if (action === 'delete') {
const before = state.contactSubmissions.length
state.contactSubmissions = state.contactSubmissions.filter(s => !idSet.has(s.id))
count = before - state.contactSubmissions.length
} else {
state.contactSubmissions = state.contactSubmissions.map(s => {
if (!idSet.has(s.id)) return s
count += 1
if (action === 'archive') return { ...s, archived: true }
if (action === 'unarchive') return { ...s, archived: false }
return { ...s, read: action === 'read' }
})
}
queueContactSubmissionsWrite()
res.json({ ok: true, count })
})
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
const seen = new Set()
const subscribers = state.contactSubmissions
.filter(entry => entry.subscribe === true && entry.email && !seen.has(entry.email) && seen.add(entry.email))
.map(entry => ({
name: entry.name,
email: entry.email,
subscribedAt: entry.submittedAt,
source: entry.source === 'resend-import' ? 'resend-import' : entry.message?.startsWith('Requested') ? 'download' : 'contact-form',
}))
.sort((a, b) => new Date(b.subscribedAt).getTime() - new Date(a.subscribedAt).getTime())
res.json({ subscribers, total: subscribers.length })
})
app.post('/api/admin-subscribers/export', requireAdminAuth, (_req, res) => {
const seen = new Set()
const rows = [['Name', 'Email', 'Subscribed At', 'Source']]
state.contactSubmissions
.filter(entry => entry.subscribe === true && entry.email && !seen.has(entry.email) && seen.add(entry.email))
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
.forEach(entry => {
const source = entry.message?.startsWith('Requested') ? 'download' : 'contact-form'
rows.push([entry.name, entry.email, entry.submittedAt, source])
})
const csv = rows.map(row => row.map(cell => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(',')).join('\n')
res.setHeader('Content-Type', 'text/csv')
res.setHeader('Content-Disposition', `attachment; filename="subscribers-${new Date().toISOString().slice(0, 10)}.csv"`)
res.send(csv)
})
// ── Subscribers ⇄ Resend comparison ─────────────────────────────────────────
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
function getAppSubscribers() {
const byEmail = new Map()
for (const entry of state.contactSubmissions) {
if (entry.subscribe !== true || !entry.email) continue
const email = String(entry.email).trim().toLowerCase()
if (!byEmail.has(email)) byEmail.set(email, { name: entry.name ?? '', email })
}
return byEmail
}
async function listAllResendContacts(resend, segmentId) {
const out = []
let after
for (let page = 0; page < 50; page += 1) {
const { data, error } = await resendRetry(() => resend.contacts.list({ limit: 100, ...(segmentId ? { segmentId } : {}), ...(after ? { after } : {}) }))
if (error) throw new Error(error.message || 'Resend contacts.list failed')
const rows = data?.data ?? []
out.push(...rows)
if (!data?.has_more || rows.length === 0) break
after = rows[rows.length - 1].id
await sleep(550) // Resend allows ~2 requests/second
}
return out
}
async function buildResendComparison() {
const resend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
const segmentId = process.env.RESEND_SEGMENT_ID || null
const appSubs = getAppSubscribers()
const resendContacts = await listAllResendContacts(resend)
let segmentEmails = null
if (segmentId) {
await sleep(550)
segmentEmails = new Set((await listAllResendContacts(resend, segmentId)).map(c => String(c.email).toLowerCase()))
}
const resendByEmail = new Map(resendContacts.map(c => [String(c.email).toLowerCase(), c]))
const nameOf = c => [c.first_name, c.last_name].filter(Boolean).join(' ')
const onlyInApp = []
const unsubscribedInResend = []
const notInSegment = []
let matched = 0
for (const sub of appSubs.values()) {
const contact = resendByEmail.get(sub.email)
if (!contact) { onlyInApp.push(sub); continue }
if (contact.unsubscribed) { unsubscribedInResend.push(sub); continue }
if (segmentEmails && !segmentEmails.has(sub.email)) { notInSegment.push(sub); continue }
matched += 1
}
const onlyInResend = resendContacts
.filter(c => !appSubs.has(String(c.email).toLowerCase()) && !c.unsubscribed)
.map(c => ({ name: nameOf(c), email: String(c.email).toLowerCase() }))
return {
appCount: appSubs.size,
resendCount: resendContacts.length,
resendUnsubscribedCount: resendContacts.filter(c => c.unsubscribed).length,
segmentConfigured: Boolean(segmentId),
segmentCount: segmentEmails ? segmentEmails.size : null,
matched,
onlyInApp,
onlyInResend,
unsubscribedInResend,
notInSegment,
}
}
app.get('/api/admin-subscribers/resend-compare', requireAdminAuth, async (_req, res) => {
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
res.json(await buildResendComparison())
} catch (err) {
console.error('[subscribers] resend compare failed:', err)
res.status(502).json({ message: 'Could not read contacts from Resend. Try again in a moment.' })
}
})
app.post('/api/admin-subscribers/resend-sync', requireAdminAuth, async (req, res) => {
const { action } = req.body ?? {}
if (!['add-to-resend', 'add-to-segment', 'apply-unsubscribes'].includes(action)) {
res.status(400).json({ message: 'Unknown action.' }); return
}
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const diff = await buildResendComparison()
if (action === 'apply-unsubscribes') {
const emails = new Set(diff.unsubscribedInResend.map(s => s.email))
let changed = 0
state.contactSubmissions = state.contactSubmissions.map(entry => {
if (entry.subscribe === true && emails.has(String(entry.email ?? '').trim().toLowerCase())) {
changed += 1
return { ...entry, subscribe: false }
}
return entry
})
if (changed > 0) queueContactSubmissionsWrite()
res.json({ ok: true, done: emails.size, failed: 0 }); return
}
const resend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
const segmentId = process.env.RESEND_SEGMENT_ID || null
const targets = (action === 'add-to-resend' ? diff.onlyInApp : diff.notInSegment).slice(0, 100)
if (action === 'add-to-segment' && !segmentId) {
res.status(400).json({ message: 'RESEND_SEGMENT_ID is not configured.' }); return
}
let done = 0
let failed = 0
for (const target of targets) {
try {
if (action === 'add-to-resend') {
const { firstName, lastName } = splitName(target.name)
const { error } = await resendRetry(() => resend.contacts.create({
email: target.email, firstName, lastName, unsubscribed: false,
...(segmentId ? { segments: [{ id: segmentId }] } : {}),
}))
if (error) throw new Error(error.message)
} else {
const { error } = await resendRetry(() => resend.contacts.segments.add({ email: target.email, segmentId }))
if (error) throw new Error(error.message)
}
done += 1
} catch (err) {
failed += 1
console.error(`[subscribers] resend sync failed for ${target.email}:`, err?.message ?? err)
}
await sleep(550)
}
const total = action === 'add-to-resend' ? diff.onlyInApp.length : diff.notInSegment.length
res.json({ ok: true, done, failed, remaining: Math.max(total - targets.length, 0) })
} catch (err) {
console.error('[subscribers] resend sync failed:', err)
res.status(502).json({ message: 'Resend sync failed. Try again in a moment.' })
}
})
app.get('/api/admin-broadcast/templates', requireAdminAuth, async (_req, res) => {
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const templates = []
let after
for (let page = 0; page < 5; page += 1) {
const { data, error } = await resendRetry(() => resend.templates.list({ limit: 100, ...(after ? { after } : {}) }))
if (error) throw new Error(error.message)
templates.push(...(data?.data ?? []))
if (!data?.has_more || !data.data?.length) break
after = data.data[data.data.length - 1].id
}
res.json({
templates: templates
.map(t => ({ id: t.id, name: t.name, status: t.status, updatedAt: t.updated_at, publishedAt: t.published_at }))
.sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? ''))),
})
} catch (err) {
console.error('[admin-broadcast] template list failed:', err)
res.status(502).json({ message: 'Could not load templates from Resend.' })
}
})
app.get('/api/admin-broadcast/templates/:id', requireAdminAuth, async (req, res) => {
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const { data, error } = await resendRetry(() => resend.templates.get(req.params.id))
if (error || !data) {
res.status(404).json({ message: error?.message || 'Template not found.' }); return
}
// Resend variables look like {{{KEY}}}; fill in their saved fallback values, but keep name for per-subscriber personalization.
let html = data.html ?? ''
for (const v of data.variables ?? []) {
if (/^(first_?name|name)$/i.test(v.key)) continue
if (v.fallback_value === null || v.fallback_value === undefined) continue
html = html.replace(new RegExp(`\\{\\{\\{\\s*${v.key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\}\\}\\}`, 'g'), escapeHtml(String(v.fallback_value)))
}
res.json({
id: data.id,
name: data.name,
subject: data.subject ?? '',
html,
hasUnsubscribeLink: /\{\{\{\s*RESEND_UNSUBSCRIBE_URL\s*\}\}\}/i.test(html),
unresolved: [...new Set([...html.matchAll(/\{\{\{\s*([A-Za-z0-9_]+)\s*\}\}\}/g)].map(m => m[1]))]
.filter(k => !/^(RESEND_UNSUBSCRIBE_URL|first_?name|name)$/i.test(k)),
})
} catch (err) {
console.error('[admin-broadcast] template get failed:', err)
res.status(502).json({ message: 'Could not load that template from Resend.' })
}
})
// Import chosen Resend-only contacts into the app's subscriber list (explicit, per-person opt-in by the admin).
app.post('/api/admin-subscribers/import-from-resend', requireAdminAuth, async (req, res) => {
const { emails } = req.body ?? {}
if (!Array.isArray(emails) || emails.length === 0 || emails.length > 200 || !emails.every(e => typeof e === 'string')) {
res.status(400).json({ message: 'Choose between 1 and 200 contacts to import.' }); return
}
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const diff = await buildResendComparison()
const allowed = new Map(diff.onlyInResend.map(c => [c.email, c]))
const now = new Date().toISOString()
const created = []
for (const raw of emails) {
const email = raw.trim().toLowerCase()
const contact = allowed.get(email)
if (!contact) continue // not active in Resend, or already a subscriber here
created.push({
id: randomUUID(),
submittedAt: now,
name: contact.name || email,
email,
message: 'Imported from Resend audience',
messageType: 'general',
subscribe: true,
archived: true,
read: true,
source: 'resend-import',
})
}
if (created.length > 0) {
state.contactSubmissions = [...created, ...state.contactSubmissions]
queueContactSubmissionsWrite()
}
res.json({ ok: true, imported: created.length, skipped: emails.length - created.length })
} catch (err) {
console.error('[subscribers] import from resend failed:', err)
res.status(502).json({ message: 'Import failed. Try again in a moment.' })
}
})
// ── Broadcast Email: sent as a real Resend Broadcast to the Resend segment ──────
const UNSUB_FOOTER = '<p style="margin:32px 0 0;font-size:12px;color:#888;text-align:center;"><a href="{{{RESEND_UNSUBSCRIBE_URL}}}" style="color:#888;">Unsubscribe</a></p>'
// App-side {{name}} becomes Resend's per-contact variable; guarantee an unsubscribe link exists.
function toResendBroadcastHtml(html) {
let out = html.replace(/\{\{\{?\s*(?:first_?name|name)\s*\}?\}\}/gi, '{{{FIRST_NAME|Friend}}}')
if (!/RESEND_UNSUBSCRIBE_URL/i.test(out)) {
out = /<\/body>/i.test(out) ? out.replace(/<\/body>/i, `${UNSUB_FOOTER}</body>`) : `${out}\n${UNSUB_FOOTER}`
}
return out
}
app.get('/api/admin-broadcast/audience', requireAdminAuth, async (_req, res) => {
const segmentId = process.env.RESEND_SEGMENT_ID
if (!process.env.RESEND_API_KEY || !segmentId) {
res.json({ segmentConfigured: false, count: null }); return
}
try {
const resend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY)
const contacts = await listAllResendContacts(resend, segmentId)
res.json({ segmentConfigured: true, count: contacts.filter(c => !c.unsubscribed).length })
} catch (err) {
console.error('[admin-broadcast] audience count failed:', err)
res.status(502).json({ message: 'Could not read the Resend segment.' })
}
})
app.post('/api/admin-broadcast/test', requireAdminAuth, async (req, res) => {
const { subject, html, to } = req.body ?? {}
if (typeof subject !== 'string' || !subject.trim() || typeof html !== 'string' || !html.trim()) {
res.status(400).json({ message: 'Subject and body are required.' }); return
}
if (typeof to !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to.trim())) {
res.status(400).json({ message: 'Enter a valid test email address.' }); return
}
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const preview = toResendBroadcastHtml(html)
.replace(/\{\{\{\s*FIRST_NAME\s*\|\s*([^}]*?)\s*\}\}\}/gi, (_m, fallback) => escapeHtml(fallback || 'Friend'))
.replace(/\{\{\{\s*RESEND_UNSUBSCRIBE_URL\s*\}\}\}/gi, '#')
const resend = new Resend(process.env.RESEND_API_KEY)
const { error } = await resend.emails.send({
from: getResendFromAddress(),
to: to.trim(),
replyTo: getResendReplyToAddress(),
subject: `[TEST] ${subject.trim()}`,
html: preview,
})
if (error) throw new Error(error.message)
res.json({ ok: true })
} catch (err) {
console.error('[admin-broadcast] test send failed:', err)
res.status(502).json({ message: 'Test email failed to send.' })
}
})
app.post('/api/admin-broadcast', requireAdminAuth, async (req, res) => {
const { subject, html, previewText, tagNewEpisode, scheduledAt } = req.body ?? {}
if (!subject || typeof subject !== 'string' || !subject.trim()) {
res.status(400).json({ message: 'Subject is required.' }); return
}
if (!html || typeof html !== 'string' || !html.trim()) {
res.status(400).json({ message: 'Email body is required.' }); return
}
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
const segmentId = process.env.RESEND_SEGMENT_ID
if (!segmentId) {
res.status(500).json({ message: 'RESEND_SEGMENT_ID is not configured, so there is no segment to send to.' }); return
}
let scheduled
if (typeof scheduledAt === 'string' && scheduledAt.trim()) {
const when = Date.parse(scheduledAt)
if (!Number.isFinite(when) || when < Date.now() + 60 * 1000) {
res.status(400).json({ message: 'Schedule time must be at least a minute in the future.' }); return
}
scheduled = new Date(when).toISOString()
}
try {
const resend = new Resend(process.env.RESEND_API_KEY)
let topicId
if (tagNewEpisode === true) {
const { data: topics, error: topicsError } = await resendRetry(() => resend.topics.list())
if (topicsError) throw new Error(topicsError.message)
topicId = (topics?.data ?? []).find(t => String(t.name || '').trim().toLowerCase() === 'new episode')?.id
if (!topicId) {
res.status(400).json({ message: 'No "New Episode" topic exists in Resend.' }); return
}
}
const { data, error } = await resendRetry(() => resend.broadcasts.create({
name: subject.trim().slice(0, 120),
segmentId,
from: getResendFromAddress(),
replyTo: getResendReplyToAddress(),
subject: subject.trim(),
html: toResendBroadcastHtml(html),
...(typeof previewText === 'string' && previewText.trim() ? { previewText: previewText.trim().slice(0, 200) } : {}),
...(topicId ? { topicId } : {}),
send: true,
...(scheduled ? { scheduledAt: scheduled } : {}),
}))
if (error || !data?.id) throw new Error(error?.message || 'Resend did not return a broadcast id.')
console.log(`[admin-broadcast] ${scheduled ? 'scheduled' : 'sent'} Resend broadcast ${data.id}: ${subject.trim()}`)
res.json({ ok: true, broadcastId: data.id, scheduledAt: scheduled ?? null })
} catch (err) {
console.error('[admin-broadcast] send failed:', err)
res.status(502).json({ message: `Resend rejected the broadcast: ${err instanceof Error ? err.message : 'unknown error'}` })
}
})
}
-303
View File
@@ -1,303 +0,0 @@
import { stat } from 'node:fs/promises'
import { requireAdminAuth } from '../auth.js'
import { TITUS_STUDY_FILE, TITUS_STUDY_DOWNLOAD_NAME } from '../config.js'
import { state } from '../state.js'
import {
loadSiteContentFile,
queueContactSubmissionsWrite,
normalizeContactEmailStatus,
normalizeMessageType,
incrementDownloadCount,
} from '../data.js'
import {
createTitusDownloadToken,
consumeTitusDownloadToken,
sanitizeUrl,
} from '../study-helpers.js'
import { syncContactToResend } from '../email.js'
import { DATA_FILE, MAX_CONTACT_SUBMISSIONS } from '../config.js'
import { randomUUID } from 'node:crypto'
const downloadHits = new Map()
function studyDownloadRateLimit(req, res, next) {
const ip = req.ip ?? 'unknown'
const now = Date.now()
const windowMs = 10 * 60 * 1000
const entry = downloadHits.get(ip) ?? { count: 0, start: now }
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now }
entry.count += 1
downloadHits.set(ip, entry)
// Prune stale entries to prevent unbounded growth
if (downloadHits.size > 5000) {
const cutoff = now - windowMs
for (const [k, v] of downloadHits) { if (v.start < cutoff) downloadHits.delete(k) }
}
if (entry.count > 10) {
res.status(429).json({ message: 'Too many download requests. Please wait a few minutes.' })
return
}
next()
}
function addContactSubmission({ name, email, message, messageType, subscribe }) {
const wantsWelcome = subscribe === true
const submission = {
id: randomUUID(),
submittedAt: new Date().toISOString(),
name,
email,
message,
messageType: normalizeMessageType(messageType),
subscribe: wantsWelcome,
archived: false,
emailStatus: normalizeContactEmailStatus(null, wantsWelcome),
}
state.contactSubmissions.unshift(submission)
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
queueContactSubmissionsWrite()
return submission
}
export function register(app) {
app.post('/api/study-downloads/titus', studyDownloadRateLimit, async (req, res) => {
try {
const { firstName, lastName, email, subscribe, _honey } = req.body ?? {}
if (_honey) { res.json({ ok: true }); return }
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
res.status(400).json({ message: 'First name is required.' }); return
}
if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
res.status(400).json({ message: 'Last name is required.' }); return
}
if (!email || typeof email !== 'string' || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email.trim())) {
res.status(400).json({ message: 'A valid email address is required.' }); return
}
const published = await loadSiteContentFile(DATA_FILE)
const configuredDownloadUrl = sanitizeUrl(published?.siteContent?.studyGuideDownloadUrl)
if (!configuredDownloadUrl) {
try {
await stat(TITUS_STUDY_FILE)
} catch {
res.status(503).json({ message: 'The primary study guide download URL is not configured yet.' }); return
}
}
const trimmedFirstName = firstName.trim()
const trimmedLastName = lastName.trim()
const trimmedName = `${trimmedFirstName} ${trimmedLastName}`.trim()
const trimmedEmail = email.trim()
const wantsSubscribe = subscribe !== false
addContactSubmission({ name: trimmedName, email: trimmedEmail, message: 'Requested Titus study download.', messageType: 'general', subscribe: wantsSubscribe })
if (wantsSubscribe) {
await syncContactToResend(trimmedName, trimmedEmail)
}
incrementDownloadCount('titus-study')
if (configuredDownloadUrl) {
res.json({ ok: true, downloadUrl: configuredDownloadUrl }); return
}
const token = createTitusDownloadToken(trimmedEmail)
res.json({ ok: true, downloadUrl: `/api/study-downloads/titus/file?token=${encodeURIComponent(token)}` })
} catch (err) {
console.error('[study-download] request error:', err)
res.status(500).json({ message: 'Failed to process your request. Please try again.' })
}
})
app.get('/api/study-downloads/titus/file', async (req, res) => {
const token = typeof req.query?.token === 'string' ? req.query.token : ''
if (!token || !consumeTitusDownloadToken(token)) {
res.status(403).json({ message: 'Invalid or expired download link. Submit the form again.' }); return
}
try {
await stat(TITUS_STUDY_FILE)
res.download(TITUS_STUDY_FILE, TITUS_STUDY_DOWNLOAD_NAME)
} catch {
res.status(503).json({ message: 'The Titus study file is not configured yet.' })
}
})
app.post('/api/resource-download', studyDownloadRateLimit, async (req, res) => {
try {
const { resourceId, firstName, lastName, email, subscribe, _honey } = req.body ?? {}
if (_honey) { res.json({ ok: true }); return }
if (!resourceId || typeof resourceId !== 'string') {
res.status(400).json({ message: 'Resource ID is required.' }); return
}
const published = await loadSiteContentFile(DATA_FILE)
const siteContent = published?.siteContent
function resolveResourceFromId(id) {
if (!siteContent || typeof siteContent !== 'object') return null
const customResources = Array.isArray(siteContent.customLinks)
? siteContent.customLinks.filter(link => link?.placement === 'resources')
: []
// Companion study guides: the second series' guide, and guides kept on finished books
if (id === 'guide:second') {
const guide = siteContent.secondSeries?.guide
const guideUrl = sanitizeUrl(guide?.downloadUrl)
return guideUrl ? { label: guide.title, url: guideUrl } : null
}
if (id.startsWith('guide:book:')) {
const bookId = id.slice('guide:book:'.length)
const book = Array.isArray(siteContent.finishedBooks) ? siteContent.finishedBooks.find(item => item?.id === bookId) : null
const bookGuideUrl = sanitizeUrl(book?.guide?.downloadUrl)
return bookGuideUrl ? { label: book.guide.title, url: bookGuideUrl } : null
}
if (id.startsWith('custom:')) {
const customId = id.slice('custom:'.length)
const match = customResources.find(link => link.id === customId)
return match ? { label: match.label, url: match.url } : null
}
if (id.startsWith('archived:')) {
const [, seriesId, ...linkIdParts] = id.split(':')
const linkId = linkIdParts.join(':')
const archivedSeries = Array.isArray(siteContent.archivedSeries) ? siteContent.archivedSeries : []
const series = archivedSeries.find(item => item.id === seriesId)
const link = Array.isArray(series?.resourceLinks) ? series.resourceLinks.find(item => item.id === linkId) : null
return link ? { label: link.label || series?.title, url: link.url } : null
}
const customMatch = customResources.find(link => link.id === id)
if (customMatch) return { label: customMatch.label, url: customMatch.url }
const archivedSeries = Array.isArray(siteContent.archivedSeries) ? siteContent.archivedSeries : []
for (const series of archivedSeries) {
if (!Array.isArray(series?.resourceLinks)) continue
const link = series.resourceLinks.find(item => item.id === id)
if (link) return { label: link.label || series?.title, url: link.url }
}
return null
}
const resource = resolveResourceFromId(resourceId)
if (!resource || typeof resource.url !== 'string' || !resource.url.trim()) {
res.status(400).json({ message: 'Resource not found.' }); return
}
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
res.status(400).json({ message: 'First name is required.' }); return
}
if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
res.status(400).json({ message: 'Last name is required.' }); return
}
if (!email || typeof email !== 'string' || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email.trim())) {
res.status(400).json({ message: 'A valid email address is required.' }); return
}
const trimmedFirstName = firstName.trim()
const trimmedLastName = lastName.trim()
const trimmedName = `${trimmedFirstName} ${trimmedLastName}`.trim()
const trimmedEmail = email.trim()
const wantsSubscribe = subscribe !== false
addContactSubmission({
name: trimmedName,
email: trimmedEmail,
message: `Requested resource download: ${resource.label ?? resource.url}`,
messageType: 'general',
subscribe: wantsSubscribe,
})
if (wantsSubscribe) {
await syncContactToResend(trimmedName, trimmedEmail)
}
incrementDownloadCount(`resource:${resourceId}`)
res.json({ ok: true, downloadUrl: resource.url.trim() })
} catch (err) {
console.error('[resource-download] request error:', err)
res.status(500).json({ message: 'Failed to process your request. Please try again.' })
}
})
// Collects a visitor's details, then hands back the Amazon / Apple Books link for a guide or book.
// The destination always comes from the saved site content, never from the request.
app.post('/api/store-link', studyDownloadRateLimit, async (req, res) => {
try {
const { source, store, firstName, lastName, email, subscribe, _honey } = req.body ?? {}
if (_honey) { res.json({ ok: true }); return }
if (typeof source !== 'string' || !source || (store !== 'amazon' && store !== 'apple')) {
res.status(400).json({ message: 'That link is not available.' }); return
}
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
res.status(400).json({ message: 'First name is required.' }); return
}
if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
res.status(400).json({ message: 'Last name is required.' }); return
}
if (!email || typeof email !== 'string' || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email.trim())) {
res.status(400).json({ message: 'A valid email address is required.' }); return
}
const published = await loadSiteContentFile(DATA_FILE)
const content = published?.siteContent ?? {}
const pick = (amazon, apple) => (store === 'amazon' ? amazon : apple)
let label = ''
let url = ''
if (source === 'page:primary-guide') {
label = content.studyGuideTitle
url = pick(content.studyGuideUrl, content.studyGuideAppleBooksUrl)
} else if (source === 'page:second-guide') {
const guide = content.secondSeries?.guide
label = guide?.title
url = pick(guide?.amazonUrl, guide?.appleBooksUrl)
} else if (source.startsWith('page:book-guide--')) {
const id = source.slice('page:book-guide--'.length)
const guide = (Array.isArray(content.finishedBooks) ? content.finishedBooks : []).find(b => b?.id === id)?.guide
label = guide?.title
url = pick(guide?.amazonUrl, guide?.appleBooksUrl)
} else if (source.startsWith('page:custom--')) {
const id = source.slice('page:custom--'.length)
const link = (content.customLinks ?? []).find(l => l?.id === id && l.placement === 'resources')
label = link?.label
url = pick(link?.amazonUrl, link?.appleBooksUrl)
} else if (source.startsWith('book:')) {
const id = source.slice('book:'.length)
const book = (content.customLinks ?? []).find(l => l?.id === id && l.placement === 'books')
label = book?.label
url = pick(book?.amazonUrl || book?.url, book?.appleBooksUrl)
}
const destination = sanitizeUrl(url)
if (!destination) { res.status(404).json({ message: 'That link is not available.' }); return }
const name = `${firstName.trim()} ${lastName.trim()}`.trim()
const trimmedEmail = email.trim()
const wantsSubscribe = subscribe !== false
const storeName = store === 'amazon' ? 'Amazon' : 'Apple Books'
addContactSubmission({
name,
email: trimmedEmail,
message: `Requested ${storeName} link: ${label || source}`,
messageType: 'general',
subscribe: wantsSubscribe,
})
if (wantsSubscribe) await syncContactToResend(name, trimmedEmail)
incrementDownloadCount(`store:${source}:${store}`)
res.json({ ok: true, url: destination })
} catch (err) {
console.error('[store-link] request error:', err)
res.status(500).json({ message: 'Failed to process your request. Please try again.' })
}
})
app.get('/api/admin-download-stats', requireAdminAuth, (_req, res) => {
res.json({ counts: state.downloadCounts })
})
}
-241
View File
@@ -1,241 +0,0 @@
import mammoth from 'mammoth'
import { requireAdminAuth } from '../auth.js'
import { state } from '../state.js'
import { queueEpisodeScriptsWrite } from '../data.js'
import { MAX_EPISODE_SCRIPT_LENGTH } from '../config.js'
import { fetchAllEpisodes } from './episodes.js'
import { scriptKey, scriptEpisodeNumber } from '../episode-script-keys.js'
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
// Word-boundary containment so "sin" doesn't match inside "using"/"resin".
function containsWord(haystackLower, word) {
return new RegExp(`\\b${escapeRegExp(word)}\\b`, 'i').test(haystackLower)
}
/**
* Whitespace-normalised search over episode script text, with word-boundary
* matching, an exact-phrase score boost, and a "season N" browse mode.
* Returns { episodeNumber, title, filename, season, snippet, score } for matches.
*/
function searchScripts(query) {
if (!query || query.trim().length < 2) return []
const q = query.trim()
const qLower = q.toLowerCase()
// "season N" browses every script tagged with that season, no text match needed.
const seasonQuery = /^season\s+(\d+)$/i.exec(q)
if (seasonQuery) {
const season = seasonQuery[1]
return Object.entries(state.episodeScripts)
.filter(([, entry]) => String(entry.season ?? '') === season)
.map(([key, entry]) => {
const episodeNumber = scriptEpisodeNumber(key, entry)
return {
key,
episodeNumber,
title: entry.title ?? `Episode ${episodeNumber}`,
filename: entry.filename ?? '',
season: entry.season ?? null,
snippet: '',
score: 1,
}
})
.sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber))
.slice(0, 8)
}
const words = qLower.split(/\s+/).filter(w => w.length >= 2)
if (words.length === 0) return []
const results = []
for (const [key, entry] of Object.entries(state.episodeScripts)) {
if (!entry?.text) continue
const episodeNumber = scriptEpisodeNumber(key, entry)
const text = entry.text.toLowerCase()
const matchedWords = words.filter(w => containsWord(text, w))
if (matchedWords.length === 0) continue
// Exact multi-word phrase matches rank above partial word-overlap matches.
const isExactPhrase = words.length > 1 && text.includes(qLower)
const score = isExactPhrase ? 1 : matchedWords.length / words.length
// Find a snippet around the first matching word
const firstWord = matchedWords[0]
const idx = text.indexOf(firstWord)
const start = Math.max(0, idx - 80)
const end = Math.min(entry.text.length, idx + 180)
let snippet = entry.text.slice(start, end).replace(/\s+/g, ' ').trim()
if (start > 0) snippet = '…' + snippet
if (end < entry.text.length) snippet = snippet + '…'
results.push({
key,
episodeNumber,
title: entry.title ?? `Episode ${episodeNumber}`,
filename: entry.filename ?? '',
season: entry.season ?? null,
snippet,
score,
})
}
return results.sort((a, b) => b.score - a.score).slice(0, 8)
}
export function register(app) {
// Public script search — called by the frontend global search
app.get('/api/episode-scripts/search', (req, res) => {
const q = typeof req.query.q === 'string' ? req.query.q.trim() : ''
if (!q || q.length < 2) { res.json({ results: [] }); return }
res.json({ results: searchScripts(q) })
})
// Admin: list all uploaded scripts
app.get('/api/admin-episode-scripts', requireAdminAuth, (_req, res) => {
const list = Object.entries(state.episodeScripts).map(([key, entry]) => ({
key,
episodeNumber: scriptEpisodeNumber(key, entry),
title: entry.title ?? `Episode ${episodeNumber}`,
filename: entry.filename ?? '',
season: entry.season ?? null,
uploadedAt: entry.uploadedAt ?? null,
wordCount: entry.text ? entry.text.split(/\s+/).filter(Boolean).length : 0,
}))
list.sort((a, b) => (Number(a.season) || 0) - (Number(b.season) || 0) || Number(a.episodeNumber) - Number(b.episodeNumber))
res.json({ scripts: list })
})
// Admin: upload a docx script for an episode
app.post('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, async (req, res, next) => {
// "backfill-seasons" is its own route below; don't let this one swallow it
if (req.params.episodeNumber === 'backfill-seasons') { next(); return }
const episodeNumber = req.params.episodeNumber?.trim()
if (!episodeNumber || !/^\d+$/.test(episodeNumber)) {
res.status(400).json({ message: 'Episode number must be a positive integer.' }); return
}
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
const filename = typeof req.body?.filename === 'string' ? req.body.filename.trim() : `episode-${episodeNumber}.docx`
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : `Episode ${episodeNumber}`
const seasonRaw = typeof req.body?.season === 'string' ? req.body.season.trim() : (typeof req.body?.season === 'number' ? String(req.body.season) : '')
const season = /^\d+$/.test(seasonRaw) ? seasonRaw : null
if (!dataUrl.startsWith('data:')) {
res.status(400).json({ message: 'dataUrl must be a valid data URL.' }); return
}
const base64 = dataUrl.split(',')[1] ?? ''
if (!base64) { res.status(400).json({ message: 'Empty file.' }); return }
const buffer = Buffer.from(base64, 'base64')
if (buffer.length === 0 || buffer.length > 20 * 1024 * 1024) {
res.status(400).json({ message: 'File must be between 1 byte and 20MB.' }); return
}
let text
try {
const result = await mammoth.extractRawText({ buffer })
text = result.value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim()
} catch (err) {
console.error('[episode-scripts] mammoth extraction failed:', err.message)
res.status(422).json({ message: 'Could not extract text from file. Make sure it is a valid .docx file.' }); return
}
if (!text || text.length < 10) {
res.status(422).json({ message: 'No readable text found in document.' }); return
}
// Trim to max length
if (text.length > MAX_EPISODE_SCRIPT_LENGTH) {
text = text.slice(0, MAX_EPISODE_SCRIPT_LENGTH)
}
const key = scriptKey(season, episodeNumber)
// A script with no season that this upload replaces would otherwise linger as a duplicate
if (season && state.episodeScripts[episodeNumber] && !state.episodeScripts[episodeNumber].season) {
delete state.episodeScripts[episodeNumber]
}
state.episodeScripts[key] = {
episodeNumber,
title,
filename,
season,
text,
uploadedAt: new Date().toISOString(),
wordCount: text.split(/\s+/).filter(Boolean).length,
}
queueEpisodeScriptsWrite()
res.json({
ok: true,
key,
episodeNumber,
season,
wordCount: state.episodeScripts[key].wordCount,
})
})
// Admin: fill in season numbers for existing scripts from the podcast RSS feed
// (matches by episode number; never overwrites a season that's already set)
app.post('/api/admin-episode-scripts/backfill-seasons', requireAdminAuth, async (_req, res) => {
let episodes
try {
episodes = await fetchAllEpisodes()
} catch (err) {
console.error('[episode-scripts] backfill: RSS fetch failed:', err.message)
res.status(502).json({ message: 'Could not fetch the podcast RSS feed to look up seasons.' })
return
}
const normalize = value => String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
// Episode numbers can repeat across seasons, so keep every season an episode number appears in.
const feedByEpisodeNumber = new Map()
for (const ep of episodes) {
if (!ep.episode || !ep.season) continue
const list = feedByEpisodeNumber.get(ep.episode) ?? []
list.push({ season: String(ep.season), title: normalize(ep.title) })
feedByEpisodeNumber.set(ep.episode, list)
}
let updated = 0
const stillMissing = []
for (const [key, entry] of Object.entries({ ...state.episodeScripts })) {
if (entry.season) continue
const episodeNumber = scriptEpisodeNumber(key, entry)
const candidates = feedByEpisodeNumber.get(episodeNumber) ?? []
let match = candidates.length === 1 ? candidates[0] : undefined
// Several seasons share this episode number: fall back to matching the title
if (!match && candidates.length > 1) {
const byTitle = candidates.filter(c => c.title && c.title === normalize(entry.title))
if (byTitle.length === 1) match = byTitle[0]
}
const newKey = match ? scriptKey(match.season, episodeNumber) : ''
if (!match || (newKey !== key && state.episodeScripts[newKey])) {
stillMissing.push(episodeNumber)
continue
}
delete state.episodeScripts[key]
state.episodeScripts[newKey] = { ...entry, season: match.season, episodeNumber }
updated += 1
}
if (updated > 0) queueEpisodeScriptsWrite()
res.json({ ok: true, updated, stillMissing })
})
// Admin: delete a script
app.delete('/api/admin-episode-scripts/:key', requireAdminAuth, (req, res) => {
const key = req.params.key?.trim()
if (!key || !state.episodeScripts[key]) {
res.status(404).json({ message: 'Script not found.' }); return
}
delete state.episodeScripts[key]
queueEpisodeScriptsWrite()
res.json({ ok: true })
})
}
-167
View File
@@ -1,167 +0,0 @@
import { sanitizeUrl } from '../study-helpers.js'
import { parseRssItems } from '../rss-helpers.js'
const RSS_FEED_URL = 'https://anchor.fm/s/11068d290/podcast/rss'
let episodesCache = null
let episodesCacheAt = 0
let rawXmlCache = null
const EPISODES_CACHE_TTL = 10 * 60 * 1000
export async function fetchAllEpisodes() {
const now = Date.now()
if (episodesCache && (now - episodesCacheAt) < EPISODES_CACHE_TTL) {
return episodesCache
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8000)
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() {
await fetchAllEpisodes()
return rawXmlCache
}
function toSpotifyEpisodeEmbedUrl(urlValue) {
if (!urlValue) return ''
try {
const parsed = new URL(urlValue)
if (parsed.protocol !== 'https:') return ''
const host = parsed.hostname.toLowerCase()
const parts = parsed.pathname.split('/').filter(Boolean)
if (host === 'open.spotify.com') {
if (parts[0] === 'embed' && parts[1] === 'episode' && parts[2]) {
return `https://open.spotify.com/embed/episode/${parts[2]}?utm_source=generator`
}
if (parts[0] === 'episode' && parts[1]) {
return `https://open.spotify.com/embed/episode/${parts[1]}?utm_source=generator`
}
}
} catch { return '' }
return ''
}
function decodeEscapedJsonUrl(value) {
return String(value || '').replace(/\\u002F/g, '/').replace(/\\\//g, '/')
}
function extractSpotifyEpisodeIdFromCreatorHtml(html, sourceUrl) {
const input = String(html || '')
if (!input) return ''
const sourceEpisodeSlug = /-([A-Za-z0-9]+)(?:\/|$)/.exec(sourceUrl)?.[1] ?? ''
const blockRegex = /"episodeId":"([^"]+)"[\s\S]*?"spotifyUrl":"([^"]+)"/g
let match
let firstEpisodeId = ''
while ((match = blockRegex.exec(input)) !== null) {
const episodeSlug = match[1]
const spotifyUrl = decodeEscapedJsonUrl(match[2])
const episodeId = /\/episode\/([A-Za-z0-9]+)/.exec(spotifyUrl)?.[1]
if (!firstEpisodeId && episodeId) firstEpisodeId = episodeId
if (sourceEpisodeSlug && episodeSlug === sourceEpisodeSlug && episodeId) return episodeId
}
if (firstEpisodeId) return firstEpisodeId
const urlMatch = /"spotifyUrl":"(https:\\u002F\\u002Fopen\.spotify\.com\\u002Fepisode\\u002F([A-Za-z0-9]+))/.exec(input)
return urlMatch ? (urlMatch[2] || '') : ''
}
function isAllowedSpotifyResolverHost(hostname) {
const host = String(hostname || '').toLowerCase()
return host === 'open.spotify.com' || host === 'creators.spotify.com' || host === 'anchor.fm' || host === 'podcasters.spotify.com'
}
export function register(app) {
app.get('/api/spotify/embed-url', async (req, res) => {
const incoming = typeof req.query.url === 'string' ? req.query.url.trim() : ''
const safeInput = sanitizeUrl(incoming)
if (!safeInput || safeInput.startsWith('/')) {
res.status(400).json({ message: 'A valid episode URL is required.' }); return
}
let parsed
try {
parsed = new URL(safeInput)
} catch {
res.status(400).json({ message: 'Malformed URL.' }); return
}
if (parsed.protocol !== 'https:' || !isAllowedSpotifyResolverHost(parsed.hostname)) {
res.status(400).json({ message: 'Unsupported episode URL host.' }); return
}
const directEmbed = toSpotifyEpisodeEmbedUrl(safeInput)
if (directEmbed) {
res.json({ embedUrl: directEmbed, resolvedFrom: 'direct' }); return
}
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8000)
const response = await fetch(safeInput, {
signal: controller.signal,
headers: { 'User-Agent': 'Siteforge/1.0 (+https://versebyversewithnate.us)', Accept: 'text/html' },
})
clearTimeout(timeout)
if (!response.ok) {
res.status(404).json({ message: 'Could not fetch episode page.' }); return
}
const html = await response.text()
const spotifyEpisodeId = extractSpotifyEpisodeIdFromCreatorHtml(html, safeInput)
if (!spotifyEpisodeId) {
res.status(404).json({ message: 'Could not resolve Spotify episode ID from page.' }); return
}
const embedUrl = `https://open.spotify.com/embed/episode/${spotifyEpisodeId}?utm_source=generator`
res.json({ embedUrl, resolvedFrom: 'page-fetch' })
} catch (err) {
console.error('[spotify/embed-url] resolve error:', err.message)
res.status(500).json({ message: 'Could not resolve Spotify embed URL right now.' })
}
})
app.get('/api/episodes', async (_req, res) => {
try {
const episodes = await fetchAllEpisodes()
res.json({ episodes: episodes.slice(0, 6) })
} catch (err) {
console.error('[episodes] RSS fetch error:', err.message)
res.json({ episodes: (episodesCache ?? []).slice(0, 6) })
}
})
app.get('/api/episodes/all', async (_req, res) => {
try {
const episodes = await fetchAllEpisodes()
res.json({ episodes })
} catch (err) {
console.error('[episodes/all] RSS fetch error:', err.message)
res.json({ episodes: episodesCache ?? [] })
}
})
app.get('/api/episode-audio', async (_req, res) => {
try {
const episodes = await fetchAllEpisodes()
res.json({ episodes: episodes.map(e => ({ title: e.title, audioUrl: e.audioUrl, duration: e.duration, episode: e.episode })) })
} catch (err) {
console.error('[episode-audio] RSS fetch error:', err.message)
res.json({ episodes: (episodesCache ?? []).map(e => ({ title: e.title, audioUrl: e.audioUrl, duration: e.duration, episode: e.episode })) })
}
})
}
-35
View File
@@ -1,35 +0,0 @@
import { fetchRawFeedXml } from './episodes.js'
import { getCanonicalBaseUrl } from '../email.js'
export function register(app) {
app.get('/feed.xml', async (_req, res) => {
try {
const xml = await fetchRawFeedXml()
if (!xml) {
res.status(503).send('Feed temporarily unavailable')
return
}
const baseUrl = getCanonicalBaseUrl().replace(/\/$/, '')
const feedUrl = `${baseUrl}/feed.xml`
// Rewrite channel <link> and <atom:link href> to our canonical domain
const rewritten = xml
.replace(
/<atom:link[^>]+rel="self"[^>]*\/>/,
`<atom:link href="${feedUrl}" rel="self" type="application/rss+xml"/>`
)
.replace(
/(<channel>[\s\S]*?<link>)[^<]*(\/link>)/,
`$1${baseUrl}$2`
)
res.set('Content-Type', 'application/rss+xml; charset=utf-8')
res.set('Cache-Control', 'public, max-age=1800')
res.send(rewritten)
} catch (err) {
console.error('[feed.xml]', err?.message ?? err)
res.status(502).send('Failed to fetch feed')
}
})
}
-153
View File
@@ -1,153 +0,0 @@
import { randomUUID, timingSafeEqual } from 'node:crypto'
import { state } from '../state.js'
import { queueContactSubmissionsWrite } from '../data.js'
import { MAX_CONTACT_SUBMISSIONS } from '../config.js'
const MESSAGE_ID_RE = /^<[\x21-\x7E]+>$/
const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024 // 5 MB total per email
const CP1252_EXTRA = {
'€': 0x80, '‚': 0x82, 'ƒ': 0x83, '„': 0x84, '…': 0x85, '†': 0x86, '‡': 0x87, 'ˆ': 0x88, '‰': 0x89, 'Š': 0x8a,
'‹': 0x8b, 'Œ': 0x8c, 'Ž': 0x8e, '‘': 0x91, '’': 0x92, '“': 0x93, '”': 0x94, '•': 0x95, '–': 0x96, '—': 0x97,
'˜': 0x98, '™': 0x99, 'š': 0x9a, '›': 0x9b, 'œ': 0x9c, 'ž': 0x9e, 'Ÿ': 0x9f,
}
// Text that was UTF-8 but got decoded as Latin-1/Windows-1252 ("’", "é", "ð") — undo that.
function repairMojibake(text) {
if (typeof text !== 'string' || !/[ÃÂâð][\u0080-\u00ff€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ]/.test(text)) return text
const bytes = []
for (const ch of text) {
const code = ch.codePointAt(0)
if (code <= 0xff) bytes.push(code)
else if (CP1252_EXTRA[ch] !== undefined) bytes.push(CP1252_EXTRA[ch])
else return text
}
const fixed = Buffer.from(bytes).toString('utf8')
return fixed.includes('\ufffd') ? text : fixed
}
function decodeHtmlBody(raw) {
if (typeof raw !== 'string' || !raw.trim()) return null
const s = raw.trim()
if (s.startsWith('<')) return s
try {
const decoded = Buffer.from(s.replace(/\s+/g, ''), 'base64').toString('utf8')
return decoded.trimStart().startsWith('<') ? decoded : s
} catch {
return s
}
}
function normalizeSubject(subject) {
return (subject ?? '').toLowerCase().replace(/^(re|fwd?):\s*/i, '').trim()
}
function resolveThreadId(fromEmail, normalizedSubject, inReplyTo) {
// 1. Exact In-Reply-To match
if (inReplyTo) {
const parent = state.contactSubmissions.find(s => s.messageId === inReplyTo)
if (parent?.threadId) return parent.threadId
}
// 2. Same sender + matching subject within 30 days
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
const match = state.contactSubmissions.find(s =>
s.email === fromEmail &&
normalizedSubject &&
normalizeSubject(s.message.match(/^Subject:\s*(.+)/m)?.[1] ?? '') === normalizedSubject &&
new Date(s.submittedAt).getTime() > cutoff,
)
if (match?.threadId) return match.threadId
// 3. New thread
return randomUUID()
}
function parseAttachments(raw) {
if (!Array.isArray(raw)) return []
let totalBytes = 0
const out = []
for (const a of raw.slice(0, 10)) {
if (!a || typeof a.filename !== 'string') continue
const dataStr = typeof a.content === 'string' ? a.content : ''
const size = typeof a.size === 'number' ? a.size : Math.floor(dataStr.length * 0.75)
if (totalBytes + size > MAX_ATTACHMENT_BYTES) continue
totalBytes += size
out.push({
id: randomUUID(),
filename: a.filename.slice(0, 255),
contentType: typeof a.contentType === 'string' ? a.contentType : 'application/octet-stream',
size,
data: dataStr,
})
}
return out
}
export function register(app) {
app.post('/api/inbound-email', (req, res) => {
const secret = process.env.INBOUND_EMAIL_SECRET
if (!secret) {
res.status(503).json({ message: 'Inbound email not configured.' }); return
}
const provided = req.get('x-webhook-secret') ?? ''
const a = Buffer.from(provided, 'utf8')
const b = Buffer.from(secret, 'utf8')
if (!provided || a.length !== b.length || !timingSafeEqual(a, b)) {
res.status(401).json({ message: 'Unauthorized.' }); return
}
const { from, to, subject, body, htmlBody, date, messageId, inReplyTo, attachments: rawAttachments, source } = req.body ?? {}
if (!from || typeof from !== 'string') {
res.status(400).json({ message: 'Missing from address.' }); return
}
const fromMatch = /^(.*?)\s*<([^>]+)>$/.exec(from.trim())
const fromEmail = fromMatch ? fromMatch[2].trim() : from.trim()
const fromName = fromMatch ? fromMatch[1].trim() : from.trim()
const normalizedMessageId = typeof messageId === 'string' && MESSAGE_ID_RE.test(messageId.trim()) ? messageId.trim() : ''
const normalizedInReplyTo = typeof inReplyTo === 'string' && MESSAGE_ID_RE.test(inReplyTo.trim()) ? inReplyTo.trim() : ''
if (normalizedMessageId) {
const exists = state.contactSubmissions.some(s => s.messageId === normalizedMessageId)
if (exists) {
res.json({ ok: true, duplicate: true }); return
}
}
const subjectStr = typeof subject === 'string' ? repairMojibake(subject.trim()) : ''
const threadId = resolveThreadId(fromEmail, normalizeSubject(subjectStr), normalizedInReplyTo)
const submission = {
id: randomUUID(),
threadId,
submittedAt: (typeof date === 'string' || typeof date === 'number') && Number.isFinite(Date.parse(date)) ? new Date(date).toISOString() : new Date().toISOString(),
name: fromName || fromEmail,
email: fromEmail,
message: [subjectStr ? `Subject: ${subjectStr}` : '', repairMojibake(typeof body === 'string' ? body : '')].filter(Boolean).join('\n\n'),
htmlBody: decodeHtmlBody(htmlBody),
messageType: 'general',
subscribe: false,
archived: false,
starred: false,
snoozedUntil: null,
source: 'inbound-email',
inboundTo: typeof to === 'string' ? to : '',
messageId: normalizedMessageId,
attachments: parseAttachments(rawAttachments),
emailStatus: {
welcome: { status: 'not-applicable', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null },
adminNotification: { status: 'not-applicable', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null },
adminReply: { status: 'pending', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null },
},
}
state.contactSubmissions.unshift(submission)
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
queueContactSubmissionsWrite()
console.log(`[inbound-email] received from ${fromEmail} — subject: ${subjectStr || '(none)'} — thread: ${threadId}`)
res.json({ ok: true })
})
}
-266
View File
@@ -1,266 +0,0 @@
import { randomUUID } from 'node:crypto'
import { Resend } from 'resend'
import { requireAdminAuth } from '../auth.js'
import { state } from '../state.js'
import { queueNewsletterIssuesWrite } from '../data.js'
import { escapeHtml } from '../helpers.js'
import { resendRetry } from '../email.js'
const BASE = 'https://versebyversewithnate.us'
const NEW_EPISODE_TOPIC_NAME = 'new episode'
function truncate(str, max) {
const clean = String(str || '').trim()
if (clean.length <= max) return clean
return `${clean.slice(0, max - 1).trimEnd()}…`
}
function stripSubjectPrefix(subject) {
return String(subject || '').replace(/^New Episode:?\s*-?\s*/i, '').trim()
}
function slugify(str) {
return String(str || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80)
}
function uniqueSlug(base) {
const root = slugify(base) || 'issue'
let candidate = root
let n = 2
while (state.newsletterIssues.some(i => i.slug === candidate)) {
candidate = `${root}-${n}`
n += 1
}
return candidate
}
function extractDescription(text, previewText) {
if (previewText && previewText.trim()) return truncate(previewText, 200)
const paragraphs = String(text || '')
.split(/\n{2,}/)
.map(p => p.replace(/\s+/g, ' ').trim())
.filter(Boolean)
const candidate = paragraphs.find(p => p.length > 60 && !/^[◆✦✍✎▶]/.test(p) && p !== p.toUpperCase())
return truncate(candidate || paragraphs[paragraphs.length - 1] || '', 200)
}
// Strip the raw {{{RESEND_UNSUBSCRIBE_URL}}} placeholder link — it only resolves inside an actual sent email.
function stripUnsubscribeLink(html) {
return String(html || '').replace(
/<p[^>]*>\s*<a[^>]*href="\{\{\{RESEND_UNSUBSCRIBE_URL\}\}\}"[\s\S]*?<\/a>\s*<\/p>/i,
'',
)
}
function buildIssuePageHtml(issue) {
const canonical = `${BASE}/newsletter/${encodeURIComponent(issue.slug)}`
const ogImage = `${BASE}/images/banner.png`
const title = escapeHtml(issue.title)
const description = escapeHtml(issue.description)
const metaBlock = `
<title>${title} — Verse by Verse with Nate</title>
<meta name="description" content="${description}"/>
<meta property="og:type" content="article"/>
<meta property="og:site_name" content="Verse by Verse with Nate"/>
<meta property="og:url" content="${escapeHtml(canonical)}"/>
<meta property="og:title" content="${title}"/>
<meta property="og:description" content="${description}"/>
<meta property="og:image" content="${escapeHtml(ogImage)}"/>
<meta property="og:image:alt" content="${title}"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="${title}"/>
<meta name="twitter:description" content="${description}"/>
<meta name="twitter:image" content="${escapeHtml(ogImage)}"/>
<link rel="canonical" href="${escapeHtml(canonical)}"/>
<style>
/* The source HTML is a Resend/React Email template built for a narrow email-client
viewport: its outer wrapper table uses align="left" (which browsers translate to
float: left) and relies on the surrounding white to be invisible in a ~600px inbox
pane. In a full-width browser tab that leaves a left-stuck column on a white field.
Force it to sit centered on the site's dark background instead. */
html, body { background-color: #0a0a08 !important; margin: 0; padding: 0; }
table[align="left"] { float: none !important; margin-left: auto !important; margin-right: auto !important; }
</style>
</head>`
const topBar = `
<div style="background:#050505;border-bottom:1px solid rgba(184,151,42,0.25);padding:14px 20px;font-family:Georgia,serif">
<div style="max-width:640px;margin:0 auto;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<a href="${BASE}/" style="color:#e8d9b5;text-decoration:none;font-size:15px;letter-spacing:0.04em">Verse by Verse with Nate</a>
<a href="${BASE}/newsletter" style="color:#B8972A;text-decoration:none;font-size:12px;letter-spacing:0.14em;text-transform:uppercase">&larr; All issues</a>
</div></div>`
const ctaBlock = `
<table border="0" width="100%" cellpadding="0" cellspacing="0" role="presentation" align="center">
<tbody><tr><td align="center" style="padding:40px 20px 56px;background-color:#080807;border-top:1px solid rgba(184,151,42,0.2)">
<table width="580" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width:580px;width:100%">
<tbody><tr><td align="center" style="padding:0;font-family:Georgia,serif">
<p style="margin:0 0 8px;font-size:12px;letter-spacing:0.18em;text-transform:uppercase;color:#B8972A">Stay in the Word</p>
<p style="margin:0 0 22px;font-size:22px;line-height:1.3;color:#f0e9cc">Get the next issue in your inbox</p>
<a href="${BASE}/subscribe" style="display:inline-block;padding:13px 34px;font-size:12px;letter-spacing:0.16em;text-transform:uppercase;color:#0c0c08;background:#B8972A;text-decoration:none;border-radius:4px;font-weight:bold">Subscribe by Email</a>
<p style="margin:26px 0 0;font-size:13px"><a href="${BASE}/newsletter" style="color:#8a7e55;text-decoration:none">&larr; Back to the newsletter archive</a></p>
</td></tr></tbody></table>
</td></tr></tbody></table>
</body>`
let html = stripUnsubscribeLink(issue.html)
html = html.replace(/<title>[\s\S]*?<\/title>/i, '')
html = /<\/head>/i.test(html) ? html.replace(/<\/head>/i, metaBlock) : html.replace(/<head[^>]*>/i, m => `${m}${metaBlock.replace('</head>', '')}`)
html = html.replace(/<body[^>]*>/i, m => `${m}${topBar}`)
html = /<\/body>/i.test(html) ? html.replace(/<\/body>/i, ctaBlock) : `${html}${ctaBlock}`
return html
}
export function register(app) {
// ── Admin: browse Resend broadcasts, flagged by New Episode topic ────────
app.get('/api/admin/newsletter/resend-broadcasts', requireAdminAuth, async (_req, res) => {
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const [{ data: topicsData, error: topicsError }, { data: listData, error: listError }] = await Promise.all([
resendRetry(() => resend.topics.list()),
resendRetry(() => resend.broadcasts.list()),
])
if (topicsError) throw new Error(topicsError.message)
if (listError) throw new Error(listError.message)
const newEpisodeTopic = (topicsData?.data ?? []).find(t => String(t.name || '').trim().toLowerCase() === NEW_EPISODE_TOPIC_NAME)
const sentBroadcasts = (listData?.data ?? []).filter(b => b.status === 'sent')
// Fetch a few at a time so a long broadcast history can't exceed Resend's requests-per-second limit.
const details = []
for (let i = 0; i < sentBroadcasts.length; i += 4) {
const chunk = await Promise.all(sentBroadcasts.slice(i, i + 4).map(b => resendRetry(() => resend.broadcasts.get(b.id))))
details.push(...chunk)
}
const broadcasts = details.map(({ data }, i) => {
const summary = sentBroadcasts[i]
if (!data) return null
return {
id: data.id,
name: data.name,
subject: data.subject,
sentAt: data.sent_at ?? summary.sent_at,
topicId: data.topic_id ?? null,
matchesNewEpisodeTopic: !!newEpisodeTopic && data.topic_id === newEpisodeTopic.id,
alreadyPublished: state.newsletterIssues.some(issue => issue.resendBroadcastId === data.id),
}
}).filter(Boolean).sort((a, b) => new Date(b.sentAt ?? 0).getTime() - new Date(a.sentAt ?? 0).getTime())
res.json({ broadcasts, newEpisodeTopicFound: !!newEpisodeTopic })
} catch (err) {
console.error('[newsletter] failed to list Resend broadcasts:', err)
res.status(502).json({ message: 'Could not reach Resend.' })
}
})
// ── Admin: manage published issues ────────────────────────────────────────
app.get('/api/admin/newsletter/issues', requireAdminAuth, (_req, res) => {
const issues = [...state.newsletterIssues]
.sort((a, b) => new Date(b.sentAt ?? b.publishedAt).getTime() - new Date(a.sentAt ?? a.publishedAt).getTime())
.map(({ html, ...rest }) => rest)
res.json({ issues })
})
app.post('/api/admin/newsletter/issues', requireAdminAuth, async (req, res) => {
const { broadcastId, title: titleOverride, slug: slugOverride, public: isPublic } = req.body ?? {}
if (!broadcastId || typeof broadcastId !== 'string') {
res.status(400).json({ message: 'broadcastId is required.' }); return
}
if (state.newsletterIssues.some(i => i.resendBroadcastId === broadcastId)) {
res.status(409).json({ message: 'This broadcast has already been published.' }); return
}
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const { data, error } = await resend.broadcasts.get(broadcastId)
if (error || !data) throw new Error(error?.message ?? 'Broadcast not found.')
if (!data.html) { res.status(400).json({ message: 'This broadcast has no HTML content.' }); return }
const title = typeof titleOverride === 'string' && titleOverride.trim()
? titleOverride.trim().slice(0, 200)
: stripSubjectPrefix(data.subject) || data.name || 'Newsletter Issue'
const slug = typeof slugOverride === 'string' && slugOverride.trim()
? uniqueSlug(slugOverride)
: uniqueSlug(title)
const issue = {
id: randomUUID(),
resendBroadcastId: data.id,
slug,
title,
subject: data.subject ?? '',
description: extractDescription(data.text, data.preview_text),
html: data.html,
sentAt: data.sent_at ?? null,
publishedAt: new Date().toISOString(),
public: isPublic !== false,
}
state.newsletterIssues.push(issue)
queueNewsletterIssuesWrite()
const { html, ...rest } = issue
res.json({ ok: true, issue: rest })
} catch (err) {
console.error('[newsletter] failed to publish issue:', err)
res.status(502).json({ message: 'Could not fetch broadcast content from Resend.' })
}
})
app.patch('/api/admin/newsletter/issues/:id', requireAdminAuth, (req, res) => {
const issue = state.newsletterIssues.find(i => i.id === req.params.id)
if (!issue) { res.status(404).json({ message: 'Issue not found.' }); return }
if (typeof req.body?.title === 'string' && req.body.title.trim()) issue.title = req.body.title.trim().slice(0, 200)
if (typeof req.body?.slug === 'string' && req.body.slug.trim() && req.body.slug.trim() !== issue.slug) {
const candidate = slugify(req.body.slug)
if (!candidate) { res.status(400).json({ message: 'Invalid slug.' }); return }
if (state.newsletterIssues.some(i => i.id !== issue.id && i.slug === candidate)) {
res.status(409).json({ message: 'That slug is already in use.' }); return
}
issue.slug = candidate
}
if (typeof req.body?.public === 'boolean') issue.public = req.body.public
queueNewsletterIssuesWrite()
const { html, ...rest } = issue
res.json({ ok: true, issue: rest })
})
app.delete('/api/admin/newsletter/issues/:id', requireAdminAuth, (req, res) => {
const idx = state.newsletterIssues.findIndex(i => i.id === req.params.id)
if (idx === -1) { res.status(404).json({ message: 'Issue not found.' }); return }
state.newsletterIssues.splice(idx, 1)
queueNewsletterIssuesWrite()
res.json({ ok: true })
})
// ── Public ─────────────────────────────────────────────────────────────────
app.get('/api/newsletter/issues', (_req, res) => {
const issues = state.newsletterIssues
.filter(i => i.public !== false)
.sort((a, b) => new Date(b.sentAt ?? b.publishedAt).getTime() - new Date(a.sentAt ?? a.publishedAt).getTime())
.map(({ html, ...rest }) => rest)
res.json({ issues })
})
app.get('/newsletter/:slug', (req, res, next) => {
const issue = state.newsletterIssues.find(i => i.slug === req.params.slug && i.public !== false)
if (!issue) { next(); return }
res.type('html').send(buildIssuePageHtml(issue))
})
}
-670
View File
@@ -1,670 +0,0 @@
import { randomUUID } from 'node:crypto'
import { readFile, unlink, mkdir } from 'node:fs/promises'
import { writeFile } from '../atomic-write.js'
import path from 'node:path'
import multer from 'multer'
import rateLimit from 'express-rate-limit'
import { createHash, timingSafeEqual } from 'node:crypto'
import { parseCookies, cookieSecureFlag } from '../helpers.js'
import { requireAdminAuth } from '../auth.js'
import {
sendNuggetsUploadNotification,
sendNuggetsHostWelcomeEmail,
sendNuggetsHostOtpEmail,
getCanonicalBaseUrl,
} from '../email.js'
import { markSeafileFileSeen } from '../nuggets-seafile-watcher.js'
import {
NUGGETS_UPLOAD_HOSTS_FILE,
NUGGETS_UPLOAD_HISTORY_FILE,
NUGGETS_PORTAL_ANNOUNCEMENT_FILE,
NUGGETS_PORTAL_GUIDELINES_FILE,
NUGGETS_NOTIFY_EMAIL,
SEAFILE_URL,
SEAFILE_TOKEN,
SEAFILE_NUGGETS_REPO_ID,
SEAFILE_EPISODE_INFO_DIR,
SEAFILE_EPISODE_UPLOAD_DIR,
SEAFILE_NUGGETS_SHARE_URL,
HOST_SESSION_COOKIE,
HOST_SESSION_TTL_MS,
DATA_DIR,
} from '../config.js'
// ── In-memory host sessions ───────────────────────────────────────────────
const hostSessions = new Map() // token → { hostId, hostName, expiresAt }
function createHostSession(hostId, hostName) {
const token = randomUUID()
hostSessions.set(token, { hostId, hostName, expiresAt: Date.now() + HOST_SESSION_TTL_MS })
return token
}
function getHostSession(req) {
const cookies = parseCookies(req.headers.cookie)
const token = cookies[HOST_SESSION_COOKIE]
if (!token) return null
const session = hostSessions.get(token)
if (!session || session.expiresAt <= Date.now()) {
hostSessions.delete(token)
return null
}
return session
}
function setHostSessionCookie(res, token) {
res.append(
'Set-Cookie',
`${HOST_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(HOST_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
)
}
function clearHostSessionCookie(res) {
res.append(
'Set-Cookie',
`${HOST_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
)
}
function requireHostAuth(req, res, next) {
if (!getHostSession(req)) {
res.status(401).json({ message: 'Not authenticated.' })
return
}
next()
}
// ── In-memory pending OTP store ───────────────────────────────────────────
const OTP_TTL_MS = 10 * 60 * 1000 // 10 minutes
const pendingOtpStore = new Map() // otpToken → { hostId, hostName, code, expiresAt, attempts }
function generateOtpCode() {
// 6-digit numeric code
return String(Math.floor(100000 + Math.random() * 900000))
}
// ── Host credentials data ─────────────────────────────────────────────────
function hashPassword(pw) {
return createHash('sha256').update(String(pw)).digest('hex')
}
function passwordsMatch(plain, hash) {
const a = Buffer.from(hashPassword(plain), 'utf8')
const b = Buffer.from(hash, 'utf8')
if (a.length !== b.length) return false
return timingSafeEqual(a, b)
}
async function loadUploadHosts() {
try {
const raw = await readFile(NUGGETS_UPLOAD_HOSTS_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return { hosts: [] }
}
}
async function saveUploadHosts(data) {
await writeFile(NUGGETS_UPLOAD_HOSTS_FILE, JSON.stringify(data, null, 2), 'utf8')
}
// ── History helpers ───────────────────────────────────────────────────────
export async function loadHistory() {
try {
const raw = await readFile(NUGGETS_UPLOAD_HISTORY_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return { entries: [] }
}
}
async function appendHistory(entry) {
const data = await loadHistory()
data.entries.unshift({ ...entry, uploadedAt: new Date().toISOString() })
// Keep last 1000 entries
if (data.entries.length > 1000) data.entries = data.entries.slice(0, 1000)
await writeFile(NUGGETS_UPLOAD_HISTORY_FILE, JSON.stringify(data, null, 2), 'utf8')
}
// ── Announcement helpers ──────────────────────────────────────────────────
async function loadAnnouncement() {
try {
const raw = await readFile(NUGGETS_PORTAL_ANNOUNCEMENT_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return { text: '', updatedAt: null }
}
}
async function saveAnnouncement(text) {
const data = { text: String(text ?? '').trim(), updatedAt: new Date().toISOString() }
await writeFile(NUGGETS_PORTAL_ANNOUNCEMENT_FILE, JSON.stringify(data, null, 2), 'utf8')
return data
}
// ── Guidelines helpers ────────────────────────────────────────────────────
const DEFAULT_GUIDELINES = [
'Export as MP3 or WAV at 44.1 kHz, 128 kbps minimum.',
'File name: YYYY-MM-DD-EpisodeName.mp3 (e.g. 2026-09-09-Romans-5.mp3).',
'Recommended length: 8–15 minutes. Keep a 0.5s lead-in and 1s fade-out.',
'Normalize to -1 dBFS before exporting.',
'Match the episode name here to the file name exactly so search stays clean.',
]
async function loadGuidelines() {
try {
const raw = await readFile(NUGGETS_PORTAL_GUIDELINES_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return { lines: DEFAULT_GUIDELINES, updatedAt: null }
}
}
async function saveGuidelines(lines) {
const data = { lines, updatedAt: new Date().toISOString() }
await writeFile(NUGGETS_PORTAL_GUIDELINES_FILE, JSON.stringify(data, null, 2), 'utf8')
return data
}
// ── Multer (disk storage, temp dir) ──────────────────────────────────────
const UPLOAD_TEMP_DIR = path.join(DATA_DIR, 'upload-tmp')
const upload = multer({
dest: UPLOAD_TEMP_DIR,
limits: { fileSize: 500 * 1024 * 1024 }, // 500 MB
})
// ── Seafile helpers ───────────────────────────────────────────────────────
function seafileConfigured() {
return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID)
}
async function getSeafileUploadLink(dir) {
const url = `${SEAFILE_URL}/api2/repos/${SEAFILE_NUGGETS_REPO_ID}/upload-link/?p=${encodeURIComponent(dir)}`
const res = await fetch(url, {
headers: { Authorization: `Token ${SEAFILE_TOKEN}` },
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Seafile upload-link failed (${res.status}): ${body}`)
}
// Seafile returns a quoted string: "https://..."
const text = await res.text()
return text.replace(/^"|"$/g, '')
}
async function uploadToSeafile(uploadLink, fileBuffer, filename, parentDir) {
const formData = new FormData()
formData.append('file', new Blob([fileBuffer]), filename)
formData.append('parent_dir', parentDir)
formData.append('filename', filename)
formData.append('replace', '1')
const res = await fetch(uploadLink, {
method: 'POST',
headers: { Authorization: `Token ${SEAFILE_TOKEN}` },
body: formData,
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Seafile upload failed (${res.status}): ${body}`)
}
await res.text()
}
// ── Rate limiters ─────────────────────────────────────────────────────────
const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many login attempts. Wait 15 minutes and try again.' },
skipSuccessfulRequests: true,
})
const otpRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many OTP attempts. Wait 15 minutes and try again.' },
})
// ── Route registration ────────────────────────────────────────────────────
export function register(app) {
// Ensure temp upload dir exists (non-blocking; created well before any upload)
mkdir(UPLOAD_TEMP_DIR, { recursive: true }).catch(err => {
console.error('[nuggets-upload] could not create upload-tmp dir:', err.message)
})
// ── Host auth ─────────────────────────────────────────────────────────
app.get('/api/nuggets-upload/me', async (req, res) => {
const session = getHostSession(req)
if (!session) {
res.json({ authenticated: false })
return
}
const data = await loadUploadHosts()
const host = data.hosts.find(h => h.id === session.hostId)
res.json({
authenticated: true,
hostName: session.hostName,
configured: seafileConfigured(),
twoFaEnabled: Boolean(host?.twoFa),
})
})
app.post('/api/nuggets-upload/login', loginRateLimiter, async (req, res) => {
const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''
const password = typeof req.body?.password === 'string' ? req.body.password : ''
if (!email || !password) {
res.status(400).json({ message: 'Email and password are required.' })
return
}
const data = await loadUploadHosts()
const host = data.hosts.find(h => h.email === email && h.active)
if (!host || !passwordsMatch(password, host.passwordHash)) {
res.status(401).json({ message: 'Invalid email or password.' })
return
}
// Check if 2FA is enabled for this host
if (host.twoFa) {
const code = generateOtpCode()
const otpToken = randomUUID()
pendingOtpStore.set(otpToken, {
hostId: host.id,
hostName: host.name,
code,
expiresAt: Date.now() + OTP_TTL_MS,
attempts: 0,
})
sendNuggetsHostOtpEmail({ to: host.email, name: host.name, code }).catch(
err => console.error('[nuggets-otp] send error:', err),
)
res.json({ requiresOtp: true, otpToken })
return
}
const token = createHostSession(host.id, host.name)
setHostSessionCookie(res, token)
res.json({ ok: true, hostName: host.name })
})
app.post('/api/nuggets-upload/verify-otp', otpRateLimiter, (req, res) => {
const otpToken = typeof req.body?.otpToken === 'string' ? req.body.otpToken : ''
const code = typeof req.body?.code === 'string' ? req.body.code.trim() : ''
if (!otpToken || !code) {
res.status(400).json({ message: 'OTP token and code are required.' })
return
}
const pending = pendingOtpStore.get(otpToken)
if (!pending || pending.expiresAt <= Date.now()) {
pendingOtpStore.delete(otpToken)
res.status(401).json({ message: 'Code expired. Please sign in again.' })
return
}
pending.attempts += 1
if (pending.attempts > 5) {
pendingOtpStore.delete(otpToken)
res.status(429).json({ message: 'Too many attempts. Please sign in again.' })
return
}
if (pending.code !== code) {
res.status(401).json({ message: 'Incorrect code. Try again.' })
return
}
pendingOtpStore.delete(otpToken)
const sessionToken = createHostSession(pending.hostId, pending.hostName)
setHostSessionCookie(res, sessionToken)
res.json({ ok: true, hostName: pending.hostName })
})
app.post('/api/nuggets-upload/logout', (req, res) => {
const cookies = parseCookies(req.headers.cookie)
const token = cookies[HOST_SESSION_COOKIE]
if (token) hostSessions.delete(token)
clearHostSessionCookie(res)
res.json({ ok: true })
})
app.post('/api/nuggets-upload/change-password', requireHostAuth, async (req, res) => {
const session = getHostSession(req)
const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : ''
if (!currentPassword || !newPassword) {
res.status(400).json({ message: 'Current and new password are required.' })
return
}
if (newPassword.length < 8) {
res.status(400).json({ message: 'New password must be at least 8 characters.' })
return
}
const data = await loadUploadHosts()
const idx = data.hosts.findIndex(h => h.id === session.hostId)
if (idx === -1) {
res.status(404).json({ message: 'Host account not found.' })
return
}
if (!passwordsMatch(currentPassword, data.hosts[idx].passwordHash)) {
res.status(401).json({ message: 'Current password is incorrect.' })
return
}
data.hosts[idx].passwordHash = hashPassword(newPassword)
await saveUploadHosts(data)
res.json({ ok: true })
})
app.post('/api/nuggets-upload/2fa/enable', requireHostAuth, async (req, res) => {
const session = getHostSession(req)
const data = await loadUploadHosts()
const idx = data.hosts.findIndex(h => h.id === session.hostId)
if (idx === -1) {
res.status(404).json({ message: 'Host account not found.' })
return
}
data.hosts[idx].twoFa = true
await saveUploadHosts(data)
res.json({ ok: true })
})
app.post('/api/nuggets-upload/2fa/disable', requireHostAuth, async (req, res) => {
const session = getHostSession(req)
const data = await loadUploadHosts()
const idx = data.hosts.findIndex(h => h.id === session.hostId)
if (idx === -1) {
res.status(404).json({ message: 'Host account not found.' })
return
}
data.hosts[idx].twoFa = false
await saveUploadHosts(data)
res.json({ ok: true })
})
// ── Episode upload (audio file + metadata) ───────────────────────────
app.post('/api/nuggets-upload/upload', requireHostAuth, (req, res, next) => {
upload.single('file')(req, res, err => {
if (!err) return next()
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ message: 'File exceeds the 500 MB limit.' })
}
console.error('[nuggets-upload] multer error:', err.message)
return res.status(500).json({ message: `File upload error: ${err.message}` })
})
}, async (req, res) => {
if (!req.file) {
res.status(400).json({ message: 'No file received.' })
return
}
const session = getHostSession(req)
const episodeName = typeof req.body?.episodeName === 'string' ? req.body.episodeName.trim() : ''
const passage = typeof req.body?.passage === 'string' ? req.body.passage.trim() : ''
const description = typeof req.body?.description === 'string' ? req.body.description.trim() : ''
const originalName = req.file.originalname || req.file.filename
const tempPath = req.file.path
if (!episodeName || !passage) {
await unlink(tempPath).catch(() => {})
res.status(400).json({ message: 'Episode name and passage are required.' })
return
}
if (!seafileConfigured()) {
await unlink(tempPath).catch(() => {})
res.status(503).json({ message: 'Seafile is not configured on this server. Contact the admin.' })
return
}
try {
const fileBuffer = await readFile(tempPath)
// Upload audio to /Episode Upload/
const audioLink = await getSeafileUploadLink(SEAFILE_EPISODE_UPLOAD_DIR)
await uploadToSeafile(audioLink, fileBuffer, originalName, SEAFILE_EPISODE_UPLOAD_DIR)
await unlink(tempPath).catch(() => {})
// Build and upload info file to /Episode Info/
const now = new Date()
const dateStr = now.toISOString().slice(0, 10)
const infoContent = [
`Episode: ${episodeName}`,
`Host: ${session?.hostName ?? 'Unknown'}`,
`Passage: ${passage}`,
`Audio File: ${originalName}`,
`Submitted: ${now.toUTCString()}`,
'',
'Description:',
description || '(none)',
].join('\n')
const safeEpName = episodeName.replace(/[^a-z0-9 _-]/gi, '').trim().replace(/\s+/g, '-').slice(0, 60)
const infoFilename = `${dateStr}-${safeEpName}-info.txt`
const infoBuffer = Buffer.from(infoContent, 'utf8')
const infoLink = await getSeafileUploadLink(SEAFILE_EPISODE_INFO_DIR)
await uploadToSeafile(infoLink, infoBuffer, infoFilename, SEAFILE_EPISODE_INFO_DIR)
console.log(`[nuggets-upload] ${session?.hostName ?? 'unknown'} uploaded "${episodeName}" (${originalName})`)
// Mark audio file as seen so the Seafile watcher doesn't double-notify
await markSeafileFileSeen(originalName)
// Persist to history
await appendHistory({
hostId: session?.hostId ?? '',
hostName: session?.hostName ?? '',
episodeName,
passage,
description,
filename: originalName,
infoFile: infoFilename,
})
// Send admin notification (fire-and-forget)
sendNuggetsUploadNotification({
to: NUGGETS_NOTIFY_EMAIL,
source: 'portal',
hostName: session?.hostName ?? '',
episodeName,
passage,
description,
filename: originalName,
infoFile: infoFilename,
}).catch(err => console.error('[nuggets-upload] notify error:', err))
res.json({
ok: true,
episodeName,
filename: originalName,
infoFile: infoFilename,
seafileUrl: SEAFILE_NUGGETS_SHARE_URL,
})
} catch (err) {
await unlink(tempPath).catch(() => {})
console.error('[nuggets-upload] upload error:', err.message)
res.status(500).json({ message: `Upload failed: ${err.message}` })
}
})
// ── Host: episode history ─────────────────────────────────────────────
app.get('/api/nuggets-upload/history', requireHostAuth, async (req, res) => {
const session = getHostSession(req)
const data = await loadHistory()
const entries = data.entries.filter(e => e.hostId === session.hostId)
res.json({ entries })
})
// ── Public: announcement ──────────────────────────────────────────────
app.get('/api/nuggets-upload/announcement', async (_req, res) => {
const data = await loadAnnouncement()
res.json(data)
})
// ── Public: recording guidelines ─────────────────────────────────────
app.get('/api/nuggets-upload/guidelines', async (_req, res) => {
const data = await loadGuidelines()
res.json(data)
})
// ── Admin: manage upload host accounts ───────────────────────────────
app.get('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (_req, res) => {
const data = await loadUploadHosts()
// Strip password hashes from the response
const hosts = data.hosts.map(({ passwordHash: _ph, ...h }) => h)
res.json({ hosts })
})
app.post('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (req, res) => {
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''
const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''
const password = typeof req.body?.password === 'string' ? req.body.password : ''
if (!name || !email || !password) {
res.status(400).json({ message: 'Name, email, and password are required.' })
return
}
if (password.length < 8) {
res.status(400).json({ message: 'Password must be at least 8 characters.' })
return
}
const data = await loadUploadHosts()
if (data.hosts.some(h => h.email === email)) {
res.status(409).json({ message: 'An account with that email already exists.' })
return
}
const host = {
id: randomUUID(),
name,
email,
passwordHash: hashPassword(password),
active: true,
twoFa: false,
createdAt: new Date().toISOString(),
}
data.hosts.push(host)
await saveUploadHosts(data)
sendNuggetsHostWelcomeEmail({
to: email,
name,
username: email,
password,
portalUrl: `${getCanonicalBaseUrl().replace(/\/$/, '')}/host`,
}).catch(err => console.error('[nuggets-host] welcome email error:', err))
const { passwordHash: _ph, ...safeHost } = host
res.json({ ok: true, host: safeHost, emailSent: true })
})
app.put('/api/admin/nuggets-upload/hosts/:id', requireAdminAuth, async (req, res) => {
const { id } = req.params
const data = await loadUploadHosts()
const idx = data.hosts.findIndex(h => h.id === id)
if (idx === -1) {
res.status(404).json({ message: 'Host not found.' })
return
}
const patch = {}
if (typeof req.body?.name === 'string') patch.name = req.body.name.trim()
if (typeof req.body?.active === 'boolean') patch.active = req.body.active
if (typeof req.body?.password === 'string' && req.body.password.length > 0) {
if (req.body.password.length < 8) {
res.status(400).json({ message: 'Password must be at least 8 characters.' })
return
}
patch.passwordHash = hashPassword(req.body.password)
}
const newPassword = typeof req.body?.password === 'string' && req.body.password.length > 0 ? req.body.password : null
data.hosts[idx] = { ...data.hosts[idx], ...patch }
await saveUploadHosts(data)
// Email the host their new password if they have an address on file
if (newPassword && data.hosts[idx].email) {
sendNuggetsHostWelcomeEmail({
to: data.hosts[idx].email,
name: data.hosts[idx].name,
username: data.hosts[idx].email,
password: newPassword,
portalUrl: `${getCanonicalBaseUrl().replace(/\/$/, '')}/host`,
}).catch(err => console.error('[nuggets-host] reset email error:', err))
}
const { passwordHash: _ph, ...safeHost } = data.hosts[idx]
res.json({ ok: true, host: safeHost, emailSent: Boolean(newPassword && data.hosts[idx].email) })
})
app.delete('/api/admin/nuggets-upload/hosts/:id', requireAdminAuth, async (req, res) => {
const { id } = req.params
const data = await loadUploadHosts()
const idx = data.hosts.findIndex(h => h.id === id)
if (idx === -1) {
res.status(404).json({ message: 'Host not found.' })
return
}
data.hosts.splice(idx, 1)
await saveUploadHosts(data)
res.json({ ok: true })
})
// ── Admin: announcement ───────────────────────────────────────────────
app.post('/api/admin/nuggets-upload/announcement', requireAdminAuth, async (req, res) => {
const text = typeof req.body?.text === 'string' ? req.body.text : ''
if (text.length > 500) {
res.status(400).json({ message: 'Announcement must be 500 characters or fewer.' })
return
}
const data = await saveAnnouncement(text)
res.json({ ok: true, ...data })
})
app.delete('/api/admin/nuggets-upload/announcement', requireAdminAuth, async (_req, res) => {
const data = await saveAnnouncement('')
res.json({ ok: true, ...data })
})
// ── Admin: recording guidelines ───────────────────────────────────────
app.post('/api/admin/nuggets-upload/guidelines', requireAdminAuth, async (req, res) => {
const raw = typeof req.body?.lines === 'string' ? req.body.lines : ''
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean)
if (lines.length > 20) {
res.status(400).json({ message: 'Maximum 20 guideline lines.' })
return
}
const data = await saveGuidelines(lines)
res.json({ ok: true, ...data })
})
app.delete('/api/admin/nuggets-upload/guidelines', requireAdminAuth, async (_req, res) => {
const data = await saveGuidelines(DEFAULT_GUIDELINES)
res.json({ ok: true, ...data })
})
// ── Admin: upload history ─────────────────────────────────────────────
app.get('/api/admin/nuggets-upload/history', requireAdminAuth, async (_req, res) => {
const data = await loadHistory()
res.json({ entries: data.entries })
})
}
-156
View File
@@ -1,156 +0,0 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { Readable } from 'node:stream'
import path from 'node:path'
import { writeFile } from '../atomic-write.js'
import { requireAdminAuth } from '../auth.js'
import { loadHistory } from './nuggets-host-upload.js'
import {
DATA_DIR,
SEAFILE_URL,
SEAFILE_TOKEN,
SEAFILE_NUGGETS_REPO_ID,
SEAFILE_EPISODE_UPLOAD_DIR,
} from '../config.js'
const WORKFLOW_FILE = path.join(DATA_DIR, 'nuggets-workflow.json')
const AUDIO_TYPES = {
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.m4a': 'audio/mp4',
'.aac': 'audio/aac',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
}
export const WORKFLOW_STEPS = ['listen', 'coldOpen', 'recordColdOpen', 'edit', 'spotify']
export async function loadWorkflow() {
try {
return JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
} catch {
return { items: {} }
}
}
export function entryId(entry) {
return createHash('sha1').update(`${entry.uploadedAt}|${entry.filename}`).digest('hex').slice(0, 16)
}
function emptyState() {
return {
steps: Object.fromEntries(WORKFLOW_STEPS.map(s => [s, null])), // step → ISO completion time
coldOpenText: '',
notes: '',
spotifyUrl: '',
}
}
function seafileConfigured() {
return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID)
}
// Portal uploads whose workflow still has steps left to do (any age, so an old one keeps showing).
export async function countPendingUploads() {
const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()])
return history.entries.filter(entry => {
const steps = wf.items[entryId(entry)]?.steps ?? {}
return !WORKFLOW_STEPS.every(step => steps[step])
}).length
}
export function register(app) {
app.get('/api/admin/nuggets-workflow', requireAdminAuth, async (_req, res) => {
const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()])
const items = history.entries.map(entry => {
const id = entryId(entry)
return { id, entry, state: { ...emptyState(), ...(wf.items[id] ?? {}) } }
})
res.json({ items })
})
app.put('/api/admin/nuggets-workflow/:id', requireAdminAuth, async (req, res) => {
const { id } = req.params
const history = await loadHistory()
if (!history.entries.some(e => entryId(e) === id)) {
res.status(404).json({ message: 'Upload not found.' })
return
}
const wf = await loadWorkflow()
const prev = { ...emptyState(), ...(wf.items[id] ?? {}) }
const body = req.body ?? {}
const next = { ...prev, steps: { ...prev.steps } }
if (body.steps && typeof body.steps === 'object') {
for (const s of WORKFLOW_STEPS) {
if (typeof body.steps[s] === 'boolean') {
// Keep the original completion time if the step was already done
next.steps[s] = body.steps[s] ? (prev.steps[s] ?? new Date().toISOString()) : null
}
}
}
if (typeof body.coldOpenText === 'string') next.coldOpenText = body.coldOpenText.slice(0, 5000)
if (typeof body.notes === 'string') next.notes = body.notes.slice(0, 5000)
if (typeof body.spotifyUrl === 'string') next.spotifyUrl = body.spotifyUrl.trim().slice(0, 500)
wf.items[id] = next
await writeFile(WORKFLOW_FILE, JSON.stringify(wf, null, 2), 'utf8')
res.json({ ok: true, state: next })
})
// Stream the uploaded audio from Seafile so it can be auditioned in the admin.
app.get('/api/admin/nuggets-workflow/:id/audio', requireAdminAuth, async (req, res) => {
if (!seafileConfigured()) {
res.status(503).json({ message: 'Seafile is not configured.' })
return
}
const history = await loadHistory()
const entry = history.entries.find(e => entryId(e) === req.params.id)
if (!entry) {
res.status(404).json({ message: 'Upload not found.' })
return
}
try {
const filePath = `${SEAFILE_EPISODE_UPLOAD_DIR.replace(/\/$/, '')}/${entry.filename}`
const linkRes = await fetch(
`${SEAFILE_URL}/api2/repos/${SEAFILE_NUGGETS_REPO_ID}/file/?p=${encodeURIComponent(filePath)}&reuse=1`,
{ headers: { Authorization: `Token ${SEAFILE_TOKEN}` }, signal: AbortSignal.timeout(10_000) },
)
if (!linkRes.ok) {
console.error(`[nuggets-workflow] Seafile link lookup for "${filePath}" failed: ${linkRes.status}`)
res.status(404).json({ message: 'Audio file not found in Seafile.' })
return
}
const downloadUrl = (await linkRes.text()).replace(/^"|"$/g, '')
const headers = {}
if (req.headers.range) headers.Range = req.headers.range
const upstream = await fetch(downloadUrl, { headers })
if (!upstream.ok && upstream.status !== 206) {
console.error(`[nuggets-workflow] Seafile download failed: ${upstream.status}`)
res.status(502).json({ message: 'Could not fetch audio from Seafile.' })
return
}
res.status(upstream.status)
for (const h of ['content-length', 'content-range', 'accept-ranges']) {
const v = upstream.headers.get(h)
if (v) res.setHeader(h, v)
}
// Seafile serves downloads as application/octet-stream, which browsers refuse to play
// (the site sends nosniff), so set the audio type from the file extension.
const ext = path.extname(entry.filename).toLowerCase()
res.setHeader('content-type', AUDIO_TYPES[ext] ?? 'audio/mpeg')
if (!res.getHeader('accept-ranges')) res.setHeader('accept-ranges', 'bytes')
const stream = Readable.fromWeb(upstream.body)
req.on('close', () => stream.destroy())
stream.on('error', () => res.end())
stream.pipe(res)
} catch (err) {
console.error('[nuggets-workflow] audio error:', err.message, err.cause?.message ?? '')
// Admin-only route, so the reason is safe to show and saves a trip to the server logs.
const reason = [err.message, err.cause?.message].filter(Boolean).join(': ')
if (!res.headersSent) res.status(500).json({ message: `Audio stream failed${reason ? ` (${reason})` : ''}.` })
}
})
}
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff. Show more