Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
853e758bab | ||
|
|
99d8b23567 | ||
|
|
5fb44f0490 | ||
|
|
9e01c04249 | ||
|
|
54575c22ef | ||
|
|
4a7c3cc967 | ||
|
|
960ee41375 | ||
|
|
e6bef529e6 | ||
|
|
35d3c9ca33 | ||
|
|
50b0e83c2b | ||
|
|
77bce2ae0c | ||
|
|
69d445013a | ||
|
|
1bf11a3669 | ||
|
|
22c0038ebb | ||
|
|
11f3d76e9b | ||
|
|
326838e5bc | ||
|
|
3a69e03e60 | ||
|
|
900a3efee3 | ||
|
|
ac1b06f945 | ||
|
|
28fc822132 | ||
|
|
f181cdb182 | ||
|
|
5c2f9113b7 | ||
|
|
13d0eb6651 | ||
|
|
99b19faede | ||
|
|
66b7bbef52 | ||
|
|
d6d2c087b2 | ||
|
|
ffb3551b7e | ||
|
|
eff119e7f0 | ||
|
|
0ab94bfcd4 | ||
|
|
f1d4909acb | ||
|
|
bb83f3e8c5 | ||
|
|
7301982bb1 | ||
|
|
899e715067 | ||
|
|
669cd6555c | ||
|
|
52568db82b | ||
|
|
b8410da5f4 | ||
|
|
640d0c1c18 | ||
|
|
b708371cc5 | ||
|
|
fcc6604926 | ||
|
|
4d37fac641 | ||
|
|
b96e9a79dc | ||
|
|
2a64f29a00 | ||
|
|
8a796964b9 | ||
|
|
e2150e1cd7 | ||
|
|
39691aedb3 | ||
|
|
752611d121 | ||
|
|
4ddedf0f81 | ||
|
|
3bd467e198 | ||
|
|
810097bb8e | ||
|
|
1eb0dc1296 | ||
|
|
652b003bd9 | ||
|
|
ddcaec0ddf | ||
|
|
2d8e4be5ea | ||
|
|
cd40ecc207 | ||
|
|
db1737baf3 | ||
|
|
b1695b2b59 | ||
|
|
0ff0b32b97 | ||
|
|
134a2c403e | ||
|
|
dad0e5691e | ||
|
|
66cdb9c4c9 | ||
|
|
af967d0049 | ||
|
|
e74bd1df5d | ||
|
|
4931bfab4d | ||
|
|
abfbb1981b | ||
|
|
fe4c09f8a3 | ||
|
|
60b23b0fba | ||
|
|
d1a68754a1 | ||
|
|
8496e227b3 | ||
|
|
3d3c7175a1 | ||
|
|
c92bb2189a | ||
|
|
841e754e16 | ||
|
|
a6e8243705 | ||
|
|
b236e69f22 | ||
|
|
8d5a983a94 | ||
|
|
22b82af3de | ||
|
|
90c72c3265 | ||
|
|
861b32d54c | ||
|
|
3c62499ad5 | ||
|
|
6621c45be8 | ||
|
|
8ba300eddc | ||
|
|
600c450eff | ||
|
|
c79589b311 | ||
|
|
4e6449f3a7 | ||
|
|
f525e7e784 | ||
|
|
f254e3ec19 | ||
|
|
06f89d6436 | ||
|
|
a4c37d0276 | ||
|
|
e64c784c7c | ||
|
|
7ac23635b2 | ||
|
|
e86594b096 | ||
|
|
65623ce0eb | ||
|
|
4002f1ee93 | ||
|
|
da5c1c5078 | ||
|
|
e158e1e53a | ||
|
|
1ddb8ee7be | ||
|
|
590885f6b1 | ||
|
|
8ff01f8e48 | ||
|
|
b999a2fe86 | ||
|
|
7ff5c453e8 | ||
|
|
b35605f411 | ||
|
|
4607532440 | ||
|
|
ac65db796a | ||
|
|
86de45dbc3 | ||
|
|
ef1d9fd8fc | ||
|
|
afff859d17 | ||
|
|
51543396e3 | ||
|
|
26a51f9089 | ||
|
|
806c3bd59c | ||
|
|
3febad351d | ||
|
|
ce12ae1d0d | ||
|
|
4940e53a98 | ||
|
|
f5c5281684 | ||
|
|
684181c34c | ||
|
|
a12a471c41 | ||
|
|
1e396644a9 | ||
|
|
6a03813914 | ||
|
|
afdc00114a | ||
|
|
29d2ace1b2 | ||
|
|
334b455d25 | ||
|
|
20fbfb4772 | ||
|
|
aac7afbdf1 | ||
|
|
4378fad66f | ||
|
|
31e79ad542 | ||
|
|
335a9cbe31 | ||
|
|
c158fb6317 | ||
|
|
6ed29ea527 | ||
|
|
274adcc093 | ||
|
|
e35e5d55bb | ||
|
|
53e200174f | ||
|
|
49e3a80c2c | ||
|
|
874dd0c89b | ||
|
|
038ef2b039 | ||
|
|
5bc8245ed2 | ||
|
|
525ae85f6a | ||
|
|
36dfa0c1d7 | ||
|
|
83d631608b | ||
|
|
63d4f9e316 | ||
|
|
0bf2d92b1f | ||
|
|
14665d06b5 | ||
|
|
e593031d0d | ||
|
|
a072076818 | ||
|
|
0d2e68ae11 | ||
|
|
07b2dc843f | ||
|
|
d2829e610e | ||
|
|
0d830d6e79 | ||
|
|
fc63a34ef2 | ||
|
|
943674e7f3 | ||
|
|
1db0a919c2 | ||
|
|
1097cb425c | ||
|
|
75c82dc8fc | ||
|
|
53405dad9b | ||
|
|
2d1c28b467 | ||
|
|
a4a1fdd891 | ||
|
|
6d4bcb2ae2 | ||
|
|
293d9bee4e | ||
|
|
0512d40fc6 | ||
|
|
0fdf7f0f50 | ||
|
|
005da43738 | ||
|
|
24d7fd7cec | ||
|
|
889baf23ca | ||
|
|
eb1d3ddd9c | ||
|
|
a35e04320a | ||
|
|
2ba9c9f7f4 | ||
|
|
9e602e29ec | ||
|
|
6acc3f1d04 | ||
|
|
bf3154378b | ||
|
|
abb4c87c41 | ||
|
|
441b907ae1 | ||
|
|
76239ee83b | ||
|
|
c56e468115 | ||
|
|
c0a79b9ed0 | ||
|
|
6a4399f7a6 | ||
|
|
78f0988d75 | ||
|
|
f53dcdec4f | ||
|
|
4c9bbee58a | ||
|
|
111b559865 | ||
|
|
f5c4557e9f | ||
|
|
bf16deea92 | ||
|
|
e2c560a1ab | ||
|
|
5e43c41b2b | ||
|
|
ee7c783de8 | ||
|
|
57fe34ba47 | ||
|
|
2732730f5e | ||
|
|
d0c949070f | ||
|
|
a263f40178 | ||
|
|
f0635753f1 | ||
|
|
de201ff356 | ||
|
|
0161a0dacd | ||
|
|
bdafac2908 | ||
|
|
ce235ba9ef | ||
|
|
7670b44d27 | ||
|
|
e75a6d0e15 | ||
|
|
ae179fb4e8 | ||
|
|
021fe24c7e | ||
|
|
939e7b9da4 | ||
|
|
1e24eaa9af | ||
|
|
5d4ba7e593 | ||
|
|
71ff0e6842 | ||
|
|
4e7905ae44 | ||
|
|
8d767b131c | ||
|
|
bc025b5dab | ||
|
|
1e4fe5f0e3 | ||
|
|
455f5c96b3 | ||
|
|
38185e43b3 | ||
|
|
87090c1c19 | ||
|
|
7ca457bb04 | ||
|
|
ff7eec9906 | ||
|
|
6444d6e0e3 | ||
|
|
d74b7523d3 | ||
|
|
43799b8c6d | ||
|
|
02dcbc33c9 | ||
|
|
b60604aa80 | ||
|
|
a7c5906c7b | ||
|
|
595a2c3ec4 | ||
|
|
369a7944f0 | ||
|
|
3a055ba8b2 | ||
|
|
178038d830 | ||
|
|
f419e7d617 | ||
|
|
0e771bfbb5 | ||
|
|
288baae676 | ||
|
|
172e4a9358 | ||
|
|
1d43875e5a | ||
|
|
f5e98826be | ||
|
|
0406cdb9dc | ||
|
|
2e406af471 | ||
|
|
f4fd177421 | ||
|
|
17c9cbbc8b | ||
|
|
fbb9a24f32 | ||
|
|
edc17b8710 | ||
|
|
16947e305c | ||
|
|
77a4f9300f | ||
|
|
118314d0d7 | ||
|
|
4b411f763f | ||
|
|
f83682879b | ||
|
|
25fa24afdf | ||
|
|
2eb98b66d0 | ||
|
|
a8f6024234 | ||
|
|
2169739910 | ||
|
|
83c3f554aa | ||
|
|
b77aba0167 | ||
|
|
dd43d0c3ea | ||
|
|
e0ed798741 | ||
|
|
9174331d2d | ||
|
|
fda9fa27a6 | ||
|
|
945a2e160e | ||
|
|
31426fccba | ||
|
|
a6f0c39c35 | ||
|
|
8450d81e7f | ||
|
|
54bc7b28da | ||
|
|
1010cb1f33 | ||
|
|
a1d5889711 | ||
|
|
3d8f02d915 | ||
|
|
d6acf85252 | ||
|
|
68fa1497a3 | ||
|
|
d42c00d6dd | ||
|
|
47887d7fbd | ||
|
|
48c2f81d00 | ||
|
|
37fc69680e | ||
|
|
1bc573147d | ||
|
|
8eae361a73 | ||
|
|
ee9d76ada7 | ||
|
|
75d490020b | ||
|
|
c4645e3475 | ||
|
|
9ad24df626 | ||
|
|
3e1b1e0fae | ||
|
|
95b7a3f211 | ||
|
|
66699270f5 | ||
|
|
c80804b055 | ||
|
|
285d837a0b | ||
|
|
0ba35e0e52 | ||
|
|
bccd1cfa60 | ||
|
|
8d95c50041 | ||
|
|
20397960d6 | ||
|
|
cb78b6522d | ||
|
|
07519b4f48 | ||
|
|
e11858c2f3 | ||
|
|
a7dc3ede21 | ||
|
|
b34a78f874 | ||
|
|
ae396da72e | ||
|
|
346ecb217c | ||
|
|
e17a818deb | ||
|
|
68f6212e9b | ||
|
|
a2c4924191 | ||
|
|
49c9994156 | ||
|
|
4983524ea4 | ||
|
|
e561bfa000 | ||
|
|
b7aa32e4b7 | ||
|
|
3e49ccce62 | ||
|
|
617998656c | ||
|
|
b187b19f18 | ||
|
|
ef6760c788 | ||
|
|
01abb2c8c6 | ||
|
|
bdd80db80a | ||
|
|
3e050c8909 | ||
|
|
5b448aef0f | ||
|
|
b18f4e88fa | ||
|
|
33ac2fb5d0 | ||
|
|
1d536827e3 | ||
|
|
b9ed8761db | ||
|
|
a58d75c425 | ||
|
|
c1fcf66ef8 | ||
|
|
b079eb5386 | ||
|
|
295c0b61f8 | ||
|
|
01143d3ec9 | ||
|
|
367ebc88b4 | ||
|
|
eee200cf9c | ||
|
|
ffa860d5bc | ||
|
|
bd047b029c | ||
|
|
da5f77c3c6 | ||
|
|
74e9f57ea5 | ||
|
|
376088a52b | ||
|
|
67f3caf40b | ||
|
|
c66be30b20 | ||
|
|
f14992350c | ||
|
|
db762c5a05 | ||
|
|
dba5f16bcd | ||
|
|
da35006a30 | ||
|
|
d74151d91d | ||
|
|
82e9443db8 | ||
|
|
af0c33282a | ||
|
|
3e38785709 | ||
|
|
c91e23a0bb | ||
|
|
e60c1fb8f9 | ||
|
|
29ef1abb74 | ||
|
|
a78ae6d6ca | ||
|
|
affd903f40 | ||
|
|
853e56bd91 | ||
|
|
becc1b5b27 | ||
|
|
0703a802ff | ||
|
|
7a48130ffb | ||
|
|
f4adeb2844 | ||
|
|
5f479402a0 | ||
|
|
7568be368e | ||
|
|
93548b1ac8 | ||
|
|
90e5c677d8 | ||
|
|
3cbb7cc409 | ||
|
|
583a9b7b66 | ||
|
|
bc3d906e64 | ||
|
|
9f0aa8bd65 | ||
|
|
272eb607c8 | ||
|
|
017a7fca88 | ||
|
|
a2c3e606bd | ||
|
|
71e429f758 | ||
|
|
fd60c8e796 | ||
|
|
42bac71fd6 | ||
|
|
475abff3cf | ||
|
|
8d3755f537 | ||
|
|
5088687b58 | ||
|
|
7c02062663 | ||
|
|
79eb72635e | ||
|
|
554c248ea7 | ||
|
|
3b91fac6a7 | ||
|
|
1551599305 | ||
|
|
7f56060d6b | ||
|
|
10bd6848ef | ||
|
|
b26103b7b1 | ||
|
|
83743cc78b | ||
|
|
ee153cf9ef | ||
|
|
88860bc42a | ||
|
|
c5cc28b36b | ||
|
|
73b8299f1a | ||
|
|
b4f1c1dbdb | ||
|
|
8a71aecb4e | ||
|
|
cc4a4b4d63 | ||
|
|
0c328057c8 | ||
|
|
5562db992b | ||
|
|
287fa72462 | ||
|
|
50459b7234 | ||
|
|
36594d5e16 | ||
|
|
0e9d112c44 | ||
|
|
063f5d2a0e | ||
|
|
6e8b360398 | ||
|
|
770d2357ac | ||
|
|
f07d8fecfb | ||
|
|
dcfce9dc24 | ||
|
|
738cc2a412 | ||
|
|
bac22950ba | ||
|
|
85af1a29ad | ||
|
|
85e8a298fd | ||
|
|
a252167052 | ||
|
|
53408ad124 | ||
|
|
d5f15486d8 | ||
|
|
afb80aaf2a | ||
|
|
3a9d11cf40 | ||
|
|
2653d95605 | ||
|
|
7772bfd6a6 | ||
|
|
74650a39b3 | ||
|
|
c46d765759 | ||
|
|
d95ed9cd8d | ||
|
|
4b01d01133 | ||
|
|
e93840c25f | ||
|
|
5b401671db | ||
|
|
f156257573 | ||
|
|
057c856df7 |
No files matched your search
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"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,5 +1,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
.vscode
|
.vscode
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Copy this file to .env and fill in your real values.
|
||||||
|
# Never commit .env to version control.
|
||||||
|
|
||||||
|
# Resend API key for the contact form
|
||||||
|
# 1. Sign up free at https://resend.com
|
||||||
|
# 2. Go to API Keys and create a new key
|
||||||
|
# 3. Paste it below
|
||||||
|
RESEND_API_KEY=re_your_api_key_here
|
||||||
|
|
||||||
|
# Optional: use a separate key for Contacts/Segments if your main key is send-only
|
||||||
|
RESEND_CONTACTS_API_KEY=
|
||||||
|
|
||||||
|
# Email address that receives contact form submissions
|
||||||
|
RESEND_TO=hello@versebyversewithnate.us
|
||||||
|
|
||||||
|
# Optional: automatically add contact form submitters to a Resend Segment
|
||||||
|
# Create a Segment in Resend and paste its ID here if you want new contacts grouped.
|
||||||
|
RESEND_SEGMENT_ID=
|
||||||
|
|
||||||
|
# "From" address shown on received emails.
|
||||||
|
# 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.
|
||||||
|
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=
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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 }}
|
||||||
@@ -2,7 +2,7 @@ name: Publish Container
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["main"]
|
branches: ["main", "beta"]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -13,6 +13,8 @@ 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
|
||||||
@@ -24,30 +26,37 @@ 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@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '22'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
- 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 GHCR
|
- name: Log in to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: git.necloud.us
|
||||||
username: ${{ github.actor }}
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Extract metadata
|
- name: Extract metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/nmemmert/siteforge
|
images: git.necloud.us/nate/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
|
||||||
|
|
||||||
@@ -56,5 +65,9 @@ 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 }}
|
||||||
@@ -17,6 +17,20 @@ dist-ssr
|
|||||||
# the image default and is preserved here for the Docker build context only.
|
# the image default and is preserved here for the Docker build context only.
|
||||||
# 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/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/*
|
||||||
@@ -28,3 +42,20 @@ dist-ssr
|
|||||||
*.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
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
FROM node:22-alpine AS build
|
# Install prod deps on the native builder platform to avoid QEMU npm crashes
|
||||||
|
# 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
|
RUN npm ci --omit=dev --legacy-peer-deps
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM node:22-alpine AS runtime
|
FROM node:22-alpine AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -13,11 +11,19 @@ WORKDIR /app
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=4173
|
ENV PORT=4173
|
||||||
|
|
||||||
COPY package*.json ./
|
ARG COMMIT_SHA=unknown
|
||||||
RUN npm ci --omit=dev
|
ARG BUILD_NUMBER=unknown
|
||||||
|
ENV BUILD_COMMIT=${COMMIT_SHA}
|
||||||
|
ENV BUILD_NUMBER=${BUILD_NUMBER}
|
||||||
|
|
||||||
COPY --from=build /app/dist ./dist
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -29,4 +35,6 @@ 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"]
|
||||||
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 127 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Artwork" xmlns="http://www.w3.org/2000/svg" width="129.1546" height="41" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 129.1546 41">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0 {
|
||||||
|
fill: #a6a6a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st1 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M119.1253.5H10.0347c-.3667,0-.729,0-1.0947.002-.3062.002-.6099.0078-.9189.0127-.6655.0166-1.3394.0576-2.0039.1768-.6694.1172-1.2915.3174-1.9009.627-.5986.3066-1.1455.7051-1.6187,1.1787-.4785.4736-.875,1.0225-1.1782,1.6211-.311.6084-.5083,1.2334-.625,1.9033-.1206.6621-.1621,1.332-.1792,2.002-.0093.3066-.0103.6143-.0151.9209v23.1143c.0049.3105.0059.6113.0151.9219.0171.6699.0586,1.3398.1792,2.0019.1167.6699.314,1.2979.625,1.9043.3032.5957.6997,1.1446,1.1782,1.6143.4731.4775,1.02.875,1.6187,1.1787.6094.3125,1.2314.5098,1.9009.6308.6646.1192,1.3384.1583,2.0039.1768.3091.0068.6128.0107.9189.0107.3657.002.728.002,1.0947.002h109.0906c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107.6699-.0185,1.3418-.0576,2-.1768.6699-.121,1.2929-.3183,1.9082-.6308.5976-.3037,1.1445-.7012,1.6172-1.1787.4765-.4697.873-1.0186,1.1816-1.6143.3066-.6064.5059-1.2344.6191-1.9043.1231-.6621.1621-1.332.1856-2.0019.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V10.0361c0-.3662,0-.7295-.0078-1.0918,0-.3066,0-.6143-.0039-.9209-.0235-.6699-.0625-1.3399-.1856-2.002-.1132-.6699-.3125-1.2949-.6191-1.9033-.3086-.5986-.7051-1.1475-1.1816-1.6211-.4727-.4736-1.0196-.8721-1.6172-1.1787-.6153-.3096-1.2383-.5098-1.9082-.627-.6582-.1191-1.3301-.1602-2-.1768-.3047-.0049-.6172-.0107-.9219-.0127-.3594-.002-.7246-.002-1.084-.002h0Z"/>
|
||||||
|
<path d="M8.9448,39.625c-.3047,0-.602-.0039-.9043-.0107-.5591-.0157-1.2222-.0469-1.8691-.1631-.6103-.1104-1.1528-.2901-1.6567-.5479-.5215-.2646-.9902-.6064-1.397-1.0166-.4141-.4062-.7544-.873-1.0205-1.3965-.2593-.5048-.4365-1.0468-.543-1.6572-.1221-.6728-.1533-1.3555-.1665-1.875-.0063-.2109-.0146-.9131-.0146-.9131V8.9443s.0088-.6914.0146-.8945c.0132-.5239.0444-1.2061.1655-1.8721.1069-.6138.2842-1.1553.5435-1.6621.2656-.5244.6065-.9937,1.0151-1.398.4136-.4141.8843-.7568,1.4023-1.0225.5137-.2607,1.0552-.439,1.6533-.5439.6733-.1206,1.356-.1509,1.8755-.1641l.9023-.0122h111.259l.9131.0127c.5127.0127,1.1953.043,1.8584.1626.6025.1055,1.1475.2847,1.6709.5479.5127.2627.9824.6055,1.3916,1.0156.4092.4062.7519.8779,1.0234,1.4043.2578.5112.4336,1.0527.5352,1.6489.1162.6309.1523,1.2788.1738,1.8872.0029.2832.0029.5874.0029.8901.0079.375.0079.7319.0079,1.0918v20.9287c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297-.0205.5889-.0566,1.2364-.1709,1.8535-.1035.6133-.2793,1.1553-.54,1.67-.2695.5195-.6123.9892-1.0156,1.3857-.4131.418-.8819.7588-1.3994,1.0225-.5186.2636-1.0479.4384-1.668.5498-.6406.1162-1.3037.1474-1.8692.1631-.2929.0068-.5996.0107-.8974.0107l-1.084.002-110.1804-.002Z"/>
|
||||||
|
<image width="2048" height="2048" transform="translate(8.0494 7.9204) scale(.0123)" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAACAAAAAgACAMAAACFeSMCAAABJlBMVEVHcEy6f+HUq+/ZsvLSsOfgtvnpwv7srv/fp/zSpu7XnPjJnObMkO/BgubBiOG8euKiWdP1t/+2a971z/+xdNrBk+KyZNynbNKkY9GsXtirc8+lWtGlVdClVNClbMuiUs6ocM7/3v+gUMy8hNy5gdm3j9K6k9S1jNC0etudTsqyic+UU8avhsyze9S2ftaudtGaTMjXwueweNKsg8ji0e+VW8Cof8SWSsWYXsL06vrNrOKTR8P78f7OtuHTvOWbYsWke8HKqN/HpN2PRMCeZcbEoNrAnNj9+f7////u5Pbp3fG9mNaLQb3byemhacmHPruJR76NUrqFPLmCOreBObaSWL6AOLV9OLR+N7R8NrOARqx7NLKGTLJ5M7B3Mq92Ma5xLKloI6CXA+vBAAAAEXRSTlMAKVue////////////////fYl6K8AAB4R5SURBVHgB7JcHkuswDEN305uj5Of+d/2E3xAzHkXe3oVGijkAnIeOji/DY2AxYglWy7Ww+SisSUKSpyLmiWziXbH1JNCOjMAzzEEKTI8a+0nYCDV4kA67fQTUExKW/WIcGSeJ0aZlvy8GKcc5oslhOCuNZVkGLovLIvA44uG3o6Ojo7c8Ff/+/b62rQkzvOZBrEWwXLXLWg1+sb0g3C51VsvVrm0XqhlayX5u/TR5R0g5IoN7jTU33kx2jEOEyUhGze8o97Sk4EWyIVvDL7ajtqPU5CmUIWqGPDgmdZJkb8CHV+AcHkzImOcgnzKgUMrQRFn+y8+Dn/Nx0NHR0ZE9L9xp+ZsU8QLM/TVHXNtUUONX2e3PVT8QdXl7qSr+WXC1m36qrtEzuKpeMmOsbQRirhlc93nNMgd77LN0l+Mfe3c9Q1dzp9RPdL5dFz5Vn0PUzmuC04THMKLgoTkeT7Kgw1EJfHwRhoqEZ4sSAfx8Kcq9U9GnweV7fBh0dHT0qq+b/iaLN1/QS1tfbNQ5XQ5ZpNjChIxybCTgHzg3cJXmqt0b6U1K5rVR9jR6SP/neVRWRoiaFLc2BtbYR/J7ME+ymBIlrznq1j9IWkw/CCQTTOyk8WUlC+JGtWseT7aupwhoZfsLdcWj12KQkj7iquarAfzIBGzvgRIcSokwSmBZLuOHwQd+FnR0dPSyd9ffkgG98qLl5U1vIpg9737/z97Z6LaNJGHwgHXOULSmg9zm/V/1pK8Ghc6kY+pkytnNTXdP/5FeLH5LPRwywn3ekXefXn/nA/f+iryvK8GR3gfyV1cUR2j4nvAcVsN6tuldp4zzp+S2HewFPTg3ogRrExtjlieC82Hi3gpDSHESH9RraiX/uFR2683mqT4XvYcbFHp0zN5J/RQ4mliv4t1A/1AJ6xNQelL/JXZxeCIL+ePrly9f1m+CO2XJksX7hvbu4t/1mL5j/WQiXpBTGorY3hdIXWt7zurmIj0roRvgG6g/W7guOgKNMrY7yRP0rjROwP87tFeOq5pFpT2D/TzGq7YkO45AeaZ2qJfyBoyLzPWF9zL/EsxoxrMkPKnTPe7dslXsQ33LXjVE/Ns8kPyvrRJGdRXKEUmIrtQvyRT2CdYvgiVLlkzIn4EfcbK/V4S9HpX89eG7LDc1uZXwmtEWEbLTd1ce69D+fFld38VVa/zz6RKlPTv4yMz3UyLDPo1JCSfThu2fLfM8HotOLknbcZmaQXQNJcd5y9i4H8gP44W8jHeJ+liVWh9G/M1z+sVQbNZhFfX2j5zwVchts9NISYx3yh9f/u9/DyxZspgf0v+HgCqm+9K+IhfVQXMuke4z3dROVXEuxgm17M/RG5/pmqn2f1D36t2fD8JlPB2yE3r9uxPNhvAifNaaUjjFewhPoDeqCXaLiJWTvWhXi2Wul/+yHs5X1rNxzzV38aeN+/SR80Hk3zSQnwXrKXZUvrvNjz9CXrPU3UHfpdC3e6zkeOH6MfDby5JF/U8O+Y75kv4e4GuO+dYd6ee6Ml6uU/azuWSOkcNvqtFyAfNcPVkb5fXwn1zpe83o3nzcJY+j96Y2x+0shHsCpjKQ+0hei9ptHtZrZ0/jab1ylQDT46MA3WneiC/BOV+oc8H8KNlUec/Ev0/8AnnFOf8Q+WG4t6xpC3R6XvwQ+VrStTPw28iSJcz6GfUr9N+B/MhIh7N7y/69tJfzzPDPT6Ic3Ap7WR7Anz5lZa4/2R6xvg5PQ2aLewd26X2S6S6o/UR8wnuEftLYjp5CeBnfazl2p9rLsoWQAXjZfmrHeCPpyGU9ZX0w766+Da5RSH/ySIJydr1LNvykYX2iStdy1kr34zf3XzGRXwoTLEvS6z9cvrpsjNzWX2tf4J8rSxb3/12xX8Vqn/eV+Mntyfld2j+rZZv+qUzsob6PzoP4AXsuO57Xydzz8anoSW5TboPudOjtQfsGsqsD7bn9sxadKG6mYnOtUBSci/Kq/fV54Af1rLB84Jz0kqUQ8xfhcpyMNyo2DpnqcSqUTyAR9p2aCHiBb7D7fuIPZ2IB1rVZbH0A4fGz2eauGk1opV6/BP4ZsmRt89dxv5Le4qYhn5iUomP83HCoryfpr+HJR+8jOss/O6V7OB5yO5ufrE+qGqyTTeTu1Ht/do/m6Xmn96BcwuOqahZkhiwty96Zos7pagP09roKw53b8ajH8y4h3jvIc0dy20Wszw/Yw1djqsDvaW82Ed/M9CDqq6XAOd5/HOLluxVQR0vRGtFk+pNSXTO6/BL4199LlixZAvjnXf79r/AIejlP1kDecv7wTaF9PVznFr7P3p/dp6cdgOMd2ikmxTotbqa35mg+7nM7fp7V1c/2LFuHoZohmdhmY75Xkzfv4fKfw7kpD8MTfIs+0Qfy0VEUtKcX4Qbzw0b7iebq3HC2R0lVm9e1XdOG8XrCoQf1cURylkFv9sChXk2lr1AfLY2g3arYKx5NkR8Cf48dgSVL1sjvxD9/effGUT/mtN+/eP9cHKvC/ruD9LRh+zUhJ/HJ+8ln7QP4mK5EXUoX9WcG9c94TY0acB3CVa+UpZ39W7iu3aifq3deV8824vtX6AbspfuINJIX1iMmR/O9ypY1q4l605f0t++G+s18DPME08ef1NcexXgBP8/nqn2F1mTqG1i/X/2Pv6b6ujYEliz5JSL5nfgvVbQnf0H+MPI3xPm+An/APbz3HD7H8iT9iQjo4+MmopPoRPxPt9pxWJxdVbMxPEEDwRPKCXepoVW5Ph6y+7Vbye4bcymE+bgeR0GPjqLYm9hu68jjeCqGU998VL/lQqb5+IQ4uW7YiMrRr+FrOMLjKF9RjVML072D1PUdh3vO74HeRJ3TqVW8F6wjH/ZgYMmSJWW33zkf8iuK4MfB/h3is6XPyXzGeol/0aBe8AP54eqj+sJ4Aa9i/axeWa1a484jBdrlozXi/L1q0rLczI7BY/PTgt2euktFoS8tYZ4MR1BEutffK9/Mtp+pjO+1Un7bLuvl4kW9PtGJHm+yHbyV/9pM9ybN+fxj8S+3C3/tto/vvdeEeLv2bTGOI7ccXifbyawJNv3fHL8D1s+AR8qSdbTfoR/ou3427hdnVDyjL/J9sc6tfUtfhhfxhkZlPtmPu/NY1dE6n+7itMkxWr9tM8P8oqZXcxyX8z+wn/XZ2yrge/HiEVRXtmq9YrPae5lUmm+h98WiQt06jceLrJ/P582zvYli514Rh2qarakxVqtc3FeBTm5RpM74mo6QzNLKmkDJ8uL6GfAwWbLYX6f+duIX/c3QX+W5kr98XCfEP10dpUfyn/DEZrAnkCazaGCPK5+naaB+u55N52FcfmvdXD7QLLMjNstM7tKD9OtKTm+Q3XSa0/X381xX4paFvaEmjXoUL467Q2/9BsGZ6odea3zU3wHkVaxNj5DXqrIek/dmR3FemVBuU+63iv1cO7KrmOTt8Dxy7yzay6vBRBH0TdeUijy6fgY8RpYs9Dfwl/wYqxGZH/VEPi/kkTw54XdH8ksC5PV+g/77t9wo+5fb9rWiO0o2AkkBOCk9m8VUa5+wS+foPq/vfcj+rculOnmMQNzeg3W1a7Cc4jdi+Dw6F0fOAL/hcg3bPmSi/4Hy+heLh6Femcd7Slft3rVnb9RsKvUSSjrDuJfXzrxk5Z0d9Q3eJPqtzNa7AkuWvJ/9nu3DevQbeu4/M+z7sZ2UfiXvx9ft3cfHyXp43w300bPMP+0T/k8pL9UxxHIckpPacnu87OZdLDvaPqVtG01aftuqXZszyRXG9G/XcAO0bwc75kKC89hAdvQacXDd5/PTE3qukxoeIQJexUn3eGXCvuEYKTQ3qDTuR7yGGPFGvIuAilsT7yex7/pF8hq771fAkiWL/dM5P7mvdMf6q4j9p4sN6Dv5Q3q39yvzDX6afn67XfqDevykPe8dwg1BufjWUOV8+KF2Yb1/S/RqBofzqwD0HdVKQ23bLyn9O+QFrJNc3YD22HZPSLRB4mV3769G8iHyOqyq2KYqZfPI3lI5FPcq5sBv7IDeK9YMyl6T5kZv1ZsZ5LqZ4tW/raz3BJYs+R/3/Nnsxxry74L/k9j3H6M/Oe+j4h43kI9KfeuM+fukZ7CfHpnXTfeEtHTGm5BtMCoCeu5icBuqbxQk2+2qM9lR3SC5SRXpHk2RiPmlWzuk5InxZr1sD57j8TPgxTddxY6VzD9OCs1ntb/HeBNLk373vN6GmCk2LHGWv52scwE7smS94Qf73xz64xSx78DvWf4UyfzkThSL9G/au42/M9qDezFfTtNN9D/fN8A7fG+C2pH7RlQ3lWFO9nUbHmdiUKQ4QeUzd0kucQQkl1OLdYNCYcfc5EGz+5vzO0LTNfcUq8OlJbnH8rTbtLine truncated
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path class="st1" d="M57.1802,19.2635c2.4126,0,4.0928,1.6631,4.0928,4.0843,0,2.4299-1.7146,4.1016-4.1532,4.1016h-2.6713v4.2481h-1.9302v-12.434h4.6617ZM54.4486,25.8295h2.2145c1.6803,0,2.6367-.9047,2.6367-2.473s-.9563-2.4643-2.6281-2.4643h-2.2232v4.9373Z"/>
|
||||||
|
<path class="st1" d="M61.5577,27.1652c0-2.9124,1.7147-4.6962,4.2998-4.6962s4.2998,1.7837,4.2998,4.6962c0,2.9211-1.7061,4.7048-4.2998,4.7048s-4.2998-1.7837-4.2998-4.7048ZM68.2615,27.1652c0-2.0076-.9479-3.1622-2.404-3.1622s-2.4042,1.1546-2.4042,3.1622c0,2.0164.9479,3.171,2.4042,3.171,1.4648,0,2.404-1.1546,2.404-3.171Z"/>
|
||||||
|
<path class="st1" d="M71.1924,27.1652c0-2.8607,1.5165-4.6789,3.7913-4.6789,1.3011,0,2.3352.6549,2.8435,1.6544h.0346v-4.8772h1.8698v12.434h-1.8095v-1.5424h-.0346c-.5342,1.034-1.5682,1.6975-2.8865,1.6975-2.2921,0-3.8086-1.8268-3.8086-4.6875ZM73.0966,27.1652c0,1.9129.9219,3.1107,2.3955,3.1107,1.4304,0,2.3867-1.215,2.3867-3.1107,0-1.8785-.9563-3.1105-2.3867-3.1105-1.4736,0-2.3955,1.2063-2.3955,3.1105Z"/>
|
||||||
|
<path class="st1" d="M87.431,25.7521c-.1895-.9652-.9133-1.732-2.1283-1.732-1.439,0-2.3869,1.1977-2.3869,3.1451,0,1.9905.9565,3.1451,2.4042,3.1451,1.146,0,1.8956-.6289,2.111-1.6802h1.7924c-.1983,1.9302-1.7407,3.2399-3.9206,3.2399-2.5936,0-4.2825-1.7751-4.2825-4.7048,0-2.878,1.6888-4.6962,4.2652-4.6962,2.3352,0,3.7569,1.4993,3.9206,3.2831h-1.7751Z"/>
|
||||||
|
<path class="st1" d="M90.1809,29.1212c0-1.5856,1.215-2.5592,3.3692-2.6799l2.4816-.1464v-.6979c0-1.0082-.6808-1.6114-1.8181-1.6114-1.0772,0-1.7493.5169-1.913,1.327h-1.7578c.1034-1.6373,1.4993-2.8436,3.7396-2.8436,2.1974,0,3.6019,1.1633,3.6019,2.9814v6.2472h-1.7837v-1.4907h-.043c-.5255,1.0082-1.6715,1.6458-2.8607,1.6458-1.7751,0-3.0158-1.1029-3.0158-2.7314ZM96.0317,28.3027v-.7152l-2.2318.1378c-1.1116.0776-1.7405.5688-1.7405,1.3443,0,.7926.6549,1.3097,1.6544,1.3097,1.3011,0,2.3179-.8962,2.3179-2.0766Z"/>
|
||||||
|
<path class="st1" d="M102.9151,22.469c2.1111,0,3.4812,1.1719,3.5415,2.8436h-1.7493c-.1034-.8789-.7496-1.4304-1.7922-1.4304-1.0082,0-1.6803.4911-1.6803,1.215,0,.5686.4137.9563,1.396,1.1804l1.4734.3361c1.8527.4308,2.5592,1.1546,2.5592,2.4643,0,1.6544-1.5424,2.7919-3.6793,2.7919-2.2662,0-3.6278-1.1116-3.8001-2.835h1.8441c.1724.9479.8186,1.4133,1.9561,1.4133,1.1201,0,1.8095-.4654,1.8095-1.2063,0-.586-.3361-.9308-1.327-1.1548l-1.4734-.3445c-1.7234-.3964-2.5765-1.2236-2.5765-2.542,0-1.62,1.4133-2.7316,3.4985-2.7316Z"/>
|
||||||
|
<path class="st1" d="M110.5601,20.4872v2.1456h1.7232v1.4736h-1.7232v4.9977c0,.7842.3445,1.1373,1.1029,1.1373.1895,0,.4911-.0171.6116-.043v1.4734c-.2068.0519-.6203.0863-1.034.0863-1.8354,0-2.5506-.6979-2.5506-2.4472v-5.2044h-1.3183v-1.4736h1.3183v-2.1456h1.87Z"/>
|
||||||
|
<path class="st1" d="M117.091,22.469c2.1111,0,3.4812,1.1719,3.5415,2.8436h-1.7493c-.1034-.8789-.7496-1.4304-1.7922-1.4304-1.0082,0-1.6803.4911-1.6803,1.215,0,.5686.4137.9563,1.396,1.1804l1.4734.3361c1.8527.4308,2.5592,1.1546,2.5592,2.4643,0,1.6544-1.5424,2.7919-3.6793,2.7919-2.2662,0-3.6278-1.1116-3.8001-2.835h1.8441c.1724.9479.8186,1.4133,1.9561,1.4133,1.1201,0,1.8095-.4654,1.8095-1.2063,0-.586-.3361-.9308-1.327-1.1548l-1.4734-.3445c-1.7234-.3964-2.5765-1.2236-2.5765-2.542,0-1.62,1.4133-2.7316,3.4985-2.7316Z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st1" d="M47.2904,21.5824c-1.1094-.0821-2.0505.6196-2.5756.6196-.5327,0-1.3541-.602-2.2251-.5855-1.1436.0168-2.1976.6654-2.7873,1.6894-1.1878,2.0617-.3032,5.1168.8538,6.7898.5659.8175,1.2409,1.7387,2.1275,1.7052.8534-.0336,1.1762-.552,2.2078-.552,1.0309,0,1.3217.552,2.2242.5354.9185-.0171,1.5005-.8344,2.0623-1.6564.6501-.9489.9172-1.8676.9331-1.9162-.0203-.0077-1.7905-.6869-1.8082-2.7263-.016-1.7051,1.3918-2.5247,1.4558-2.5641-.7991-1.1769-2.0334-1.3067-2.4682-1.3389Z"/>
|
||||||
|
<path class="st1" d="M46.6059,20.478c.4712-.5694.7882-1.3624.7009-2.1512-.6781.0269-1.4983.4517-1.9846,1.0208-.4356.5041-.8181,1.3115-.7142,2.0841.7558.0584,1.5272-.3839,1.998-.9537Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st1" d="M39.8898,9.2729h.9297v5.1875h2.8057v.8018h-3.7354v-5.9893Z"/>
|
||||||
|
<path class="st1" d="M44.7189,9.3891c0-.3115.2534-.5645.5645-.5645.3076,0,.5645.2529.5645.5645,0,.3066-.2568.5605-.5645.5605-.311,0-.5645-.2539-.5645-.5605ZM44.8312,10.7543h.8965v4.5078h-.8965v-4.5078Z"/>
|
||||||
|
<path class="st1" d="M46.9381,14.0834v-.0049h.9009l.0039.0049c.1123.3398.4399.5684.9756.5684.5557,0,.9419-.2529.9419-.623v-.0078c0-.2822-.2114-.4736-.7349-.5938l-.7178-.166c-.8506-.1953-1.2451-.5811-1.2451-1.2412v-.0039c0-.7842.7305-1.3486,1.7515-1.3486,1.0293,0,1.689.5146,1.7681,1.249v.0039h-.8594v-.0078c-.0869-.3154-.4062-.5518-.9131-.5518-.4937,0-.8506.2441-.8506.6055v.0088c0,.2822.2075.4561.7139.5762l.7139.1621c.8672.2031,1.2822.5732,1.2822,1.2246v.0078c0,.835-.7969,1.4033-1.8672,1.4033-1.0879,0-1.7764-.5107-1.8638-1.2656Z"/>
|
||||||
|
<path class="st1" d="M52.133,14.0746v-2.6104h-.7051v-.71h.7051v-1.1367h.9175v1.1367h.9673v.71h-.9673v2.3945c0,.4854.1826.6846.6265.6846.1372,0,.2158-.0039.3408-.0166v.7188c-.1455.0244-.3115.0449-.4858.0449-.9917,0-1.3989-.3486-1.3989-1.2158Z"/>
|
||||||
|
<path class="st1" d="M54.9152,13.0209v-.0049c0-1.4062.8135-2.3486,2.0713-2.3486s2.0293.9092,2.0293,2.2617v.3154h-3.1958c.0166.8633.4897,1.3652,1.2329,1.3652.5767,0,.9209-.2861,1.0293-.5186l.0166-.0332.8672-.0039-.0083.0371c-.1494.5938-.772,1.2578-1.9258,1.2578-1.3198,0-2.1167-.9004-2.1167-2.3281ZM55.8322,12.601h2.291c-.0703-.8047-.5225-1.1953-1.1328-1.1953-.6143,0-1.0791.4199-1.1582,1.1953Z"/>
|
||||||
|
<path class="st1" d="M60.2047,10.7543h.9009v.6846h.0664c.2197-.4814.6514-.7715,1.3071-.7715,1.0083,0,1.5649.6016,1.5649,1.6768v2.918h-.8965v-2.7061c0-.7471-.3115-1.1172-.9717-1.1172-.6597,0-1.0703.4531-1.0703,1.1787v2.6445h-.9009v-4.5078Z"/>
|
||||||
|
<path class="st1" d="M67.6349,13.0121v-.0078c0-1.4492.8135-2.3369,2.1333-2.3369,1.3159,0,2.1294.8838,2.1294,2.3369v.0078c0,1.4531-.8096,2.3369-2.1294,2.3369-1.3237,0-2.1333-.8838-2.1333-2.3369ZM70.9801,13.0121v-.0078c0-1.001-.4399-1.5859-1.2119-1.5859-.7759,0-1.2119.585-1.2119,1.5859v.0078c0,1,.436,1.5859,1.2119,1.5859.772,0,1.2119-.5898,1.2119-1.5859Z"/>
|
||||||
|
<path class="st1" d="M73.0861,10.7543h.9004v.6846h.0664c.2202-.4814.6519-.7715,1.3076-.7715,1.0088,0,1.5645.6016,1.5645,1.6768v2.918h-.8965v-2.7061c0-.7471-.311-1.1172-.9707-1.1172-.6602,0-1.0713.4531-1.0713,1.1787v2.6445h-.9004v-4.5078Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 651 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Artwork" xmlns="http://www.w3.org/2000/svg" width="113.7899" height="26.164" version="1.1" viewBox="0 0 113.7899 26.164">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M49.6308,11.8432c2.4126,0,4.0928,1.6631,4.0928,4.0843,0,2.4299-1.7146,4.1016-4.1532,4.1016h-2.6713v4.2481h-1.9302v-12.434h4.6617ZM46.8992,18.4092h2.2145c1.6803,0,2.6367-.9047,2.6367-2.473s-.9563-2.4643-2.6281-2.4643h-2.2232v4.9373Z"/>
|
||||||
|
<path d="M54.0083,19.7448c0-2.9124,1.7147-4.6962,4.2998-4.6962s4.2998,1.7837,4.2998,4.6962c0,2.9211-1.7061,4.7048-4.2998,4.7048s-4.2998-1.7837-4.2998-4.7048ZM60.7121,19.7448c0-2.0076-.9479-3.1622-2.404-3.1622s-2.4042,1.1546-2.4042,3.1622c0,2.0164.9479,3.171,2.4042,3.171,1.4648,0,2.404-1.1546,2.404-3.171Z"/>
|
||||||
|
<path d="M63.643,19.7448c0-2.8607,1.5165-4.6789,3.7913-4.6789,1.3011,0,2.3352.6549,2.8435,1.6544h.0346v-4.8772h1.8698v12.434h-1.8095v-1.5424h-.0346c-.5342,1.034-1.5682,1.6975-2.8865,1.6975-2.2921,0-3.8086-1.8268-3.8086-4.6875ZM65.5472,19.7448c0,1.9129.9219,3.1107,2.3955,3.1107,1.4304,0,2.3867-1.215,2.3867-3.1107,0-1.8785-.9563-3.1105-2.3867-3.1105-1.4736,0-2.3955,1.2063-2.3955,3.1105Z"/>
|
||||||
|
<path d="M79.8816,18.3317c-.1895-.9652-.9133-1.732-2.1283-1.732-1.439,0-2.3869,1.1977-2.3869,3.1451,0,1.9905.9565,3.1451,2.4042,3.1451,1.146,0,1.8956-.6289,2.111-1.6802h1.7924c-.1983,1.9302-1.7407,3.2399-3.9206,3.2399-2.5936,0-4.2825-1.7751-4.2825-4.7048,0-2.878,1.6888-4.6962,4.2652-4.6962,2.3352,0,3.7569,1.4993,3.9206,3.2831h-1.7751Z"/>
|
||||||
|
<path d="M82.6315,21.7009c0-1.5856,1.215-2.5592,3.3692-2.6799l2.4816-.1464v-.6979c0-1.0082-.6808-1.6114-1.8181-1.6114-1.0772,0-1.7493.5169-1.913,1.327h-1.7578c.1034-1.6373,1.4993-2.8436,3.7396-2.8436,2.1974,0,3.6019,1.1633,3.6019,2.9814v6.2472h-1.7837v-1.4907h-.043c-.5255,1.0082-1.6715,1.6458-2.8607,1.6458-1.7751,0-3.0158-1.1029-3.0158-2.7314ZM88.4823,20.8823v-.7152l-2.2318.1378c-1.1116.0776-1.7405.5688-1.7405,1.3443,0,.7926.6549,1.3097,1.6544,1.3097,1.3011,0,2.3179-.8962,2.3179-2.0766Z"/>
|
||||||
|
<path d="M95.3657,15.0486c2.1111,0,3.4812,1.1719,3.5415,2.8436h-1.7493c-.1034-.8789-.7496-1.4304-1.7922-1.4304-1.0082,0-1.6803.4911-1.6803,1.215,0,.5686.4137.9563,1.396,1.1804l1.4734.3361c1.8527.4308,2.5592,1.1546,2.5592,2.4643,0,1.6544-1.5424,2.7919-3.6793,2.7919-2.2662,0-3.6278-1.1116-3.8001-2.835h1.8441c.1724.9479.8186,1.4133,1.9561,1.4133,1.1201,0,1.8095-.4654,1.8095-1.2063,0-.586-.3361-.9308-1.327-1.1548l-1.4734-.3445c-1.7234-.3964-2.5765-1.2236-2.5765-2.542,0-1.62,1.4133-2.7316,3.4985-2.7316Z"/>
|
||||||
|
<path d="M103.0107,13.0668v2.1456h1.7232v1.4736h-1.7232v4.9977c0,.7842.3445,1.1373,1.1029,1.1373.1895,0,.4911-.0171.6116-.043v1.4734c-.2068.0519-.6203.0863-1.034.0863-1.8354,0-2.5506-.6979-2.5506-2.4472v-5.2044h-1.3183v-1.4736h1.3183v-2.1456h1.87Z"/>
|
||||||
|
<path d="M109.5416,15.0486c2.1111,0,3.4812,1.1719,3.5415,2.8436h-1.7493c-.1034-.8789-.7496-1.4304-1.7922-1.4304-1.0082,0-1.6803.4911-1.6803,1.215,0,.5686.4137.9563,1.396,1.1804l1.4734.3361c1.8527.4308,2.5592,1.1546,2.5592,2.4643,0,1.6544-1.5424,2.7919-3.6793,2.7919-2.2662,0-3.6278-1.1116-3.8001-2.835h1.8441c.1724.9479.8186,1.4133,1.9561,1.4133,1.1201,0,1.8095-.4654,1.8095-1.2063,0-.586-.3361-.9308-1.327-1.1548l-1.4734-.3445c-1.7234-.3964-2.5765-1.2236-2.5765-2.542,0-1.62,1.4133-2.7316,3.4985-2.7316Z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M39.741,14.162c-1.1094-.0821-2.0505.6196-2.5756.6196-.5327,0-1.3541-.602-2.2251-.5855-1.1436.0168-2.1976.6654-2.7873,1.6894-1.1878,2.0617-.3032,5.1168.8538,6.7898.5659.8175,1.2409,1.7387,2.1275,1.7052.8534-.0336,1.1762-.552,2.2078-.552,1.0309,0,1.3217.552,2.2242.5354.9185-.0171,1.5005-.8344,2.0623-1.6564.6501-.9489.9172-1.8676.9331-1.9162-.0203-.0077-1.7905-.6869-1.8082-2.7263-.016-1.7051,1.3918-2.5247,1.4558-2.5641-.7991-1.1769-2.0334-1.3067-2.4682-1.3389Z"/>
|
||||||
|
<path d="M39.0565,13.0576c.4712-.5694.7882-1.3624.7009-2.1512-.6781.0269-1.4983.4517-1.9846,1.0208-.4356.5041-.8181,1.3115-.7142,2.0841.7558.0584,1.5272-.3839,1.998-.9537Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M32.3404,1.8525h.9297v5.1875h2.8057v.8018h-3.7354V1.8525Z"/>
|
||||||
|
<path d="M37.1695,1.9687c0-.3115.2534-.5645.5645-.5645.3076,0,.5645.2529.5645.5645,0,.3066-.2568.5605-.5645.5605-.311,0-.5645-.2539-.5645-.5605ZM37.2818,3.3339h.8965v4.5078h-.8965V3.3339Z"/>
|
||||||
|
<path d="M39.3887,6.663v-.0049h.9009l.0039.0049c.1123.3398.4399.5684.9756.5684.5557,0,.9419-.2529.9419-.623v-.0078c0-.2822-.2114-.4736-.7349-.5938l-.7178-.166c-.8506-.1953-1.2451-.5811-1.2451-1.2412v-.0039c0-.7842.7305-1.3486,1.7515-1.3486,1.0293,0,1.689.5146,1.7681,1.249v.0039h-.8594v-.0078c-.0869-.3154-.4062-.5518-.9131-.5518-.4937,0-.8506.2441-.8506.6055v.0088c0,.2822.2075.4561.7139.5762l.7139.1621c.8672.2031,1.2822.5732,1.2822,1.2246v.0078c0,.835-.7969,1.4033-1.8672,1.4033-1.0879,0-1.7764-.5107-1.8638-1.2656Z"/>
|
||||||
|
<path d="M44.5836,6.6543v-2.6104h-.7051v-.71h.7051v-1.1367h.9175v1.1367h.9673v.71h-.9673v2.3945c0,.4854.1826.6846.6265.6846.1372,0,.2158-.0039.3408-.0166v.7188c-.1455.0244-.3115.0449-.4858.0449-.9917,0-1.3989-.3486-1.3989-1.2158Z"/>
|
||||||
|
<path d="M47.3658,5.6005v-.0049c0-1.4062.8135-2.3486,2.0713-2.3486s2.0293.9092,2.0293,2.2617v.3154h-3.1958c.0166.8633.4897,1.3652,1.2329,1.3652.5767,0,.9209-.2861,1.0293-.5186l.0166-.0332.8672-.0039-.0083.0371c-.1494.5938-.772,1.2578-1.9258,1.2578-1.3198,0-2.1167-.9004-2.1167-2.3281ZM48.2828,5.1806h2.291c-.0703-.8047-.5225-1.1953-1.1328-1.1953-.6143,0-1.0791.4199-1.1582,1.1953Z"/>
|
||||||
|
<path d="M52.6553,3.3339h.9009v.6846h.0664c.2197-.4814.6514-.7715,1.3071-.7715,1.0083,0,1.5649.6016,1.5649,1.6768v2.918h-.8965v-2.7061c0-.7471-.3115-1.1172-.9717-1.1172-.6597,0-1.0703.4531-1.0703,1.1787v2.6445h-.9009V3.3339Z"/>
|
||||||
|
<path d="M60.0855,5.5918v-.0078c0-1.4492.8135-2.3369,2.1333-2.3369,1.3159,0,2.1294.8838,2.1294,2.3369v.0078c0,1.4531-.8096,2.3369-2.1294,2.3369-1.3237,0-2.1333-.8838-2.1333-2.3369ZM63.4307,5.5918v-.0078c0-1.001-.4399-1.5859-1.2119-1.5859-.7759,0-1.2119.585-1.2119,1.5859v.0078c0,1,.436,1.5859,1.2119,1.5859.772,0,1.2119-.5898,1.2119-1.5859Z"/>
|
||||||
|
<path d="M65.5367,3.3339h.9004v.6846h.0664c.2202-.4814.6519-.7715,1.3076-.7715,1.0088,0,1.5645.6016,1.5645,1.6768v2.918h-.8965v-2.7061c0-.7471-.311-1.1172-.9707-1.1172-.6602,0-1.0713.4531-1.0713,1.1787v2.6445h-.9004V3.3339Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path d="M25.6619,8.6162c-.0017-.2948-.0051-.5895-.0131-.8843-.0174-.6422-.0552-1.29-.1694-1.9251-.1158-.6443-.3048-1.2439-.6028-1.8293-.2929-.5753-.6755-1.1018-1.132-1.5583-.4565-.4565-.983-.8392-1.5583-1.132-.5854-.298-1.185-.487-1.8293-.6028-.6351-.1142-1.2829-.152-1.9251-.1694-.2947-.008-.5895-.0114-.8843-.0131-.35-.0022-.7-.0021-1.05-.0021h-6.8316c-.35,0-.7-.0001-1.05.0021-.2948.0017-.5895.0051-.8842.0131-.6422.0174-1.29.0552-1.9251.1694-.6443.1158-1.2439.3048-1.8293.6028-.5753.2929-1.1018.6755-1.5583,1.132s-.8392.983-1.132,1.5583c-.298.5854-.487,1.185-.6028,1.8293-.1142.6351-.152,1.2829-.1694,1.9251-.008.2947-.0114.5895-.0131.8843-.0022.35-.0021.7-.0021,1.05v6.8316c0,.35-.0001.7.0021,1.05.0017.2948.0051.5895.0131.8843.0174.6422.0552,1.29.1694,1.9251.1158.6443.3048,1.2439.6028,1.8293.2929.5753.6755,1.1018,1.132,1.5583s.983.8392,1.5583,1.132c.5854.298,1.185.487,1.8293.6028.6351.1142,1.2829.152,1.9251.1694.2947.008.5894.0114.8842.0131.35.0022.7.0021,1.05.0021h6.8316c.35,0,.7.0001,1.05-.0021.2948-.0017.5895-.0051.8843-.0131.6422-.0174,1.29-.0552,1.9251-.1694.6443-.1158,1.2439-.3048,1.8293-.6028.5753-.2929,1.1018-.6755,1.5583-1.132.4565-.4565.8392-.983,1.132-1.5583.298-.5854.487-1.185.6028-1.8293.1142-.6351.152-1.2829.1694-1.9251.008-.2947.0114-.5895.0131-.8843.0022-.35.0021-.7.0021-1.05v-6.8316c0-.35.0001-.7-.0021-1.05ZM14.7462,21.7244c-.3066,1.028-.7664,1.4337-1.6862,1.4337-.9287,0-1.3886-.4057-1.6862-1.4337-.2164-.7394-.6582-3.3904-.6582-5.0224,0-1.0189.8927-2.0829,2.3444-2.0829s2.3354,1.064,2.3354,2.0829c0,1.6321-.4418,4.283-.6492,5.0224ZM10.8509,11.6344c0-1.1993.9829-2.1821,2.2092-2.1821,1.2083,0,2.1911.9829,2.1911,2.1821,0,1.2263-.9829,2.2182-2.1911,2.2182-1.2263,0-2.2092-.9919-2.2092-2.2182ZM13.0511,7.1801c-2.5789,0-4.6708,2.0919-4.6708,4.6618,0,1.4427.6582,2.7321,1.7042,3.5797-.1894.2976-.3066.6222-.3697.9739-1.4157-1.0189-2.3264-2.6871-2.3264-4.5536,0-3.1199,2.5338-5.6627,5.6627-5.6627,3.1199,0,5.6627,2.5428,5.6627,5.6627,0,1.8665-.9288,3.5256-2.3173,4.5445-.0631-.3787-.1894-.6492-.3877-.9738,1.0369-.8386,1.7042-2.137,1.7042-3.5707,0-2.5698-2.0919-4.6618-4.6618-4.6618ZM16.0447,20.6153c.0541-.3697.1172-.7664.1623-1.1632,2.8854-1.2173,4.9142-4.0666,4.9142-7.3939,0-4.4544-3.6158-8.0611-8.0612-8.0611-4.4634,0-8.0792,3.6068-8.0792,8.0611,0,3.3273,2.0288,6.1856,4.9233,7.3939.0451.3877.1082.7935.1623,1.1542-3.5437-1.2263-6.0774-4.5806-6.0774-8.5481C3.989,7.0538,8.0467,2.9962,13.0601,2.9962c5.0044,0,9.053,4.0576,9.053,9.062,0,3.9585-2.5427,7.3218-6.0684,8.5571Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Artwork" xmlns="http://www.w3.org/2000/svg" width="113.7899" height="26.164" version="1.1" viewBox="0 0 113.7899 26.164">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M49.6308,11.8432c2.4126,0,4.0928,1.6631,4.0928,4.0843,0,2.4299-1.7146,4.1016-4.1532,4.1016h-2.6713v4.2481h-1.9302v-12.434h4.6617ZM46.8992,18.4092h2.2145c1.6803,0,2.6367-.9047,2.6367-2.473s-.9563-2.4643-2.6281-2.4643h-2.2232v4.9373Z"/>
|
||||||
|
<path class="st0" d="M54.0083,19.7448c0-2.9124,1.7147-4.6962,4.2998-4.6962s4.2998,1.7837,4.2998,4.6962c0,2.9211-1.7061,4.7048-4.2998,4.7048s-4.2998-1.7837-4.2998-4.7048ZM60.7121,19.7448c0-2.0076-.9479-3.1622-2.404-3.1622s-2.4042,1.1546-2.4042,3.1622c0,2.0164.9479,3.171,2.4042,3.171,1.4648,0,2.404-1.1546,2.404-3.171Z"/>
|
||||||
|
<path class="st0" d="M63.643,19.7448c0-2.8607,1.5165-4.6789,3.7913-4.6789,1.3011,0,2.3352.6549,2.8435,1.6544h.0346v-4.8772h1.8698v12.434h-1.8095v-1.5424h-.0346c-.5342,1.034-1.5682,1.6975-2.8865,1.6975-2.2921,0-3.8086-1.8268-3.8086-4.6875ZM65.5472,19.7448c0,1.9129.9219,3.1107,2.3955,3.1107,1.4304,0,2.3867-1.215,2.3867-3.1107,0-1.8785-.9563-3.1105-2.3867-3.1105-1.4736,0-2.3955,1.2063-2.3955,3.1105Z"/>
|
||||||
|
<path class="st0" d="M79.8816,18.3317c-.1895-.9652-.9133-1.732-2.1283-1.732-1.439,0-2.3869,1.1977-2.3869,3.1451,0,1.9905.9565,3.1451,2.4042,3.1451,1.146,0,1.8956-.6289,2.111-1.6802h1.7924c-.1983,1.9302-1.7407,3.2399-3.9206,3.2399-2.5936,0-4.2825-1.7751-4.2825-4.7048,0-2.878,1.6888-4.6962,4.2652-4.6962,2.3352,0,3.7569,1.4993,3.9206,3.2831h-1.7751Z"/>
|
||||||
|
<path class="st0" d="M82.6315,21.7009c0-1.5856,1.215-2.5592,3.3692-2.6799l2.4816-.1464v-.6979c0-1.0082-.6808-1.6114-1.8181-1.6114-1.0772,0-1.7493.5169-1.913,1.327h-1.7578c.1034-1.6373,1.4993-2.8436,3.7396-2.8436,2.1974,0,3.6019,1.1633,3.6019,2.9814v6.2472h-1.7837v-1.4907h-.043c-.5255,1.0082-1.6715,1.6458-2.8607,1.6458-1.7751,0-3.0158-1.1029-3.0158-2.7314ZM88.4823,20.8823v-.7152l-2.2318.1378c-1.1116.0776-1.7405.5688-1.7405,1.3443,0,.7926.6549,1.3097,1.6544,1.3097,1.3011,0,2.3179-.8962,2.3179-2.0766Z"/>
|
||||||
|
<path class="st0" d="M95.3657,15.0486c2.1111,0,3.4812,1.1719,3.5415,2.8436h-1.7493c-.1034-.8789-.7496-1.4304-1.7922-1.4304-1.0082,0-1.6803.4911-1.6803,1.215,0,.5686.4137.9563,1.396,1.1804l1.4734.3361c1.8527.4308,2.5592,1.1546,2.5592,2.4643,0,1.6544-1.5424,2.7919-3.6793,2.7919-2.2662,0-3.6278-1.1116-3.8001-2.835h1.8441c.1724.9479.8186,1.4133,1.9561,1.4133,1.1201,0,1.8095-.4654,1.8095-1.2063,0-.586-.3361-.9308-1.327-1.1548l-1.4734-.3445c-1.7234-.3964-2.5765-1.2236-2.5765-2.542,0-1.62,1.4133-2.7316,3.4985-2.7316Z"/>
|
||||||
|
<path class="st0" d="M103.0107,13.0668v2.1456h1.7232v1.4736h-1.7232v4.9977c0,.7842.3445,1.1373,1.1029,1.1373.1895,0,.4911-.0171.6116-.043v1.4734c-.2068.0519-.6203.0863-1.034.0863-1.8354,0-2.5506-.6979-2.5506-2.4472v-5.2044h-1.3183v-1.4736h1.3183v-2.1456h1.87Z"/>
|
||||||
|
<path class="st0" d="M109.5416,15.0486c2.1111,0,3.4812,1.1719,3.5415,2.8436h-1.7493c-.1034-.8789-.7496-1.4304-1.7922-1.4304-1.0082,0-1.6803.4911-1.6803,1.215,0,.5686.4137.9563,1.396,1.1804l1.4734.3361c1.8527.4308,2.5592,1.1546,2.5592,2.4643,0,1.6544-1.5424,2.7919-3.6793,2.7919-2.2662,0-3.6278-1.1116-3.8001-2.835h1.8441c.1724.9479.8186,1.4133,1.9561,1.4133,1.1201,0,1.8095-.4654,1.8095-1.2063,0-.586-.3361-.9308-1.327-1.1548l-1.4734-.3445c-1.7234-.3964-2.5765-1.2236-2.5765-2.542,0-1.62,1.4133-2.7316,3.4985-2.7316Z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M39.741,14.162c-1.1094-.0821-2.0505.6196-2.5756.6196-.5327,0-1.3541-.602-2.2251-.5855-1.1436.0168-2.1976.6654-2.7873,1.6894-1.1878,2.0617-.3032,5.1168.8538,6.7898.5659.8175,1.2409,1.7387,2.1275,1.7052.8534-.0336,1.1762-.552,2.2078-.552,1.0309,0,1.3217.552,2.2242.5354.9185-.0171,1.5005-.8344,2.0623-1.6564.6501-.9489.9172-1.8676.9331-1.9162-.0203-.0077-1.7905-.6869-1.8082-2.7263-.016-1.7051,1.3918-2.5247,1.4558-2.5641-.7991-1.1769-2.0334-1.3067-2.4682-1.3389Z"/>
|
||||||
|
<path class="st0" d="M39.0565,13.0576c.4712-.5694.7882-1.3624.7009-2.1512-.6781.0269-1.4983.4517-1.9846,1.0208-.4356.5041-.8181,1.3115-.7142,2.0841.7558.0584,1.5272-.3839,1.998-.9537Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M32.3404,1.8525h.9297v5.1875h2.8057v.8018h-3.7354V1.8525Z"/>
|
||||||
|
<path class="st0" d="M37.1695,1.9687c0-.3115.2534-.5645.5645-.5645.3076,0,.5645.2529.5645.5645,0,.3066-.2568.5605-.5645.5605-.311,0-.5645-.2539-.5645-.5605ZM37.2818,3.3339h.8965v4.5078h-.8965V3.3339Z"/>
|
||||||
|
<path class="st0" d="M39.3887,6.663v-.0049h.9009l.0039.0049c.1123.3398.4399.5684.9756.5684.5557,0,.9419-.2529.9419-.623v-.0078c0-.2822-.2114-.4736-.7349-.5938l-.7178-.166c-.8506-.1953-1.2451-.5811-1.2451-1.2412v-.0039c0-.7842.7305-1.3486,1.7515-1.3486,1.0293,0,1.689.5146,1.7681,1.249v.0039h-.8594v-.0078c-.0869-.3154-.4062-.5518-.9131-.5518-.4937,0-.8506.2441-.8506.6055v.0088c0,.2822.2075.4561.7139.5762l.7139.1621c.8672.2031,1.2822.5732,1.2822,1.2246v.0078c0,.835-.7969,1.4033-1.8672,1.4033-1.0879,0-1.7764-.5107-1.8638-1.2656Z"/>
|
||||||
|
<path class="st0" d="M44.5836,6.6543v-2.6104h-.7051v-.71h.7051v-1.1367h.9175v1.1367h.9673v.71h-.9673v2.3945c0,.4854.1826.6846.6265.6846.1372,0,.2158-.0039.3408-.0166v.7188c-.1455.0244-.3115.0449-.4858.0449-.9917,0-1.3989-.3486-1.3989-1.2158Z"/>
|
||||||
|
<path class="st0" d="M47.3658,5.6005v-.0049c0-1.4062.8135-2.3486,2.0713-2.3486s2.0293.9092,2.0293,2.2617v.3154h-3.1958c.0166.8633.4897,1.3652,1.2329,1.3652.5767,0,.9209-.2861,1.0293-.5186l.0166-.0332.8672-.0039-.0083.0371c-.1494.5938-.772,1.2578-1.9258,1.2578-1.3198,0-2.1167-.9004-2.1167-2.3281ZM48.2828,5.1806h2.291c-.0703-.8047-.5225-1.1953-1.1328-1.1953-.6143,0-1.0791.4199-1.1582,1.1953Z"/>
|
||||||
|
<path class="st0" d="M52.6553,3.3339h.9009v.6846h.0664c.2197-.4814.6514-.7715,1.3071-.7715,1.0083,0,1.5649.6016,1.5649,1.6768v2.918h-.8965v-2.7061c0-.7471-.3115-1.1172-.9717-1.1172-.6597,0-1.0703.4531-1.0703,1.1787v2.6445h-.9009V3.3339Z"/>
|
||||||
|
<path class="st0" d="M60.0855,5.5918v-.0078c0-1.4492.8135-2.3369,2.1333-2.3369,1.3159,0,2.1294.8838,2.1294,2.3369v.0078c0,1.4531-.8096,2.3369-2.1294,2.3369-1.3237,0-2.1333-.8838-2.1333-2.3369ZM63.4307,5.5918v-.0078c0-1.001-.4399-1.5859-1.2119-1.5859-.7759,0-1.2119.585-1.2119,1.5859v.0078c0,1,.436,1.5859,1.2119,1.5859.772,0,1.2119-.5898,1.2119-1.5859Z"/>
|
||||||
|
<path class="st0" d="M65.5367,3.3339h.9004v.6846h.0664c.2202-.4814.6519-.7715,1.3076-.7715,1.0088,0,1.5645.6016,1.5645,1.6768v2.918h-.8965v-2.7061c0-.7471-.311-1.1172-.9707-1.1172-.6602,0-1.0713.4531-1.0713,1.1787v2.6445h-.9004V3.3339Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path class="st0" d="M25.6619,8.6162c-.0017-.2948-.0051-.5895-.0131-.8843-.0174-.6422-.0552-1.29-.1694-1.9251-.1158-.6443-.3048-1.2439-.6028-1.8293-.2929-.5753-.6755-1.1018-1.132-1.5583-.4565-.4565-.983-.8392-1.5583-1.132-.5854-.298-1.185-.487-1.8293-.6028-.6351-.1142-1.2829-.152-1.9251-.1694-.2947-.008-.5895-.0114-.8843-.0131-.35-.0022-.7-.0021-1.05-.0021h-6.8316c-.35,0-.7-.0001-1.05.0021-.2948.0017-.5895.0051-.8842.0131-.6422.0174-1.29.0552-1.9251.1694-.6443.1158-1.2439.3048-1.8293.6028-.5753.2929-1.1018.6755-1.5583,1.132s-.8392.983-1.132,1.5583c-.298.5854-.487,1.185-.6028,1.8293-.1142.6351-.152,1.2829-.1694,1.9251-.008.2947-.0114.5895-.0131.8843-.0022.35-.0021.7-.0021,1.05v6.8316c0,.35-.0001.7.0021,1.05.0017.2948.0051.5895.0131.8843.0174.6422.0552,1.29.1694,1.9251.1158.6443.3048,1.2439.6028,1.8293.2929.5753.6755,1.1018,1.132,1.5583s.983.8392,1.5583,1.132c.5854.298,1.185.487,1.8293.6028.6351.1142,1.2829.152,1.9251.1694.2947.008.5894.0114.8842.0131.35.0022.7.0021,1.05.0021h6.8316c.35,0,.7.0001,1.05-.0021.2948-.0017.5895-.0051.8843-.0131.6422-.0174,1.29-.0552,1.9251-.1694.6443-.1158,1.2439-.3048,1.8293-.6028.5753-.2929,1.1018-.6755,1.5583-1.132.4565-.4565.8392-.983,1.132-1.5583.298-.5854.487-1.185.6028-1.8293.1142-.6351.152-1.2829.1694-1.9251.008-.2947.0114-.5895.0131-.8843.0022-.35.0021-.7.0021-1.05v-6.8316c0-.35.0001-.7-.0021-1.05ZM14.7462,21.7244c-.3066,1.028-.7664,1.4337-1.6862,1.4337-.9287,0-1.3886-.4057-1.6862-1.4337-.2164-.7394-.6582-3.3904-.6582-5.0224,0-1.0189.8927-2.0829,2.3444-2.0829s2.3354,1.064,2.3354,2.0829c0,1.6321-.4418,4.283-.6492,5.0224ZM10.8509,11.6344c0-1.1993.9829-2.1821,2.2092-2.1821,1.2083,0,2.1911.9829,2.1911,2.1821,0,1.2263-.9829,2.2182-2.1911,2.2182-1.2263,0-2.2092-.9919-2.2092-2.2182ZM13.0511,7.1801c-2.5789,0-4.6708,2.0919-4.6708,4.6618,0,1.4427.6582,2.7321,1.7042,3.5797-.1894.2976-.3066.6222-.3697.9739-1.4157-1.0189-2.3264-2.6871-2.3264-4.5536,0-3.1199,2.5338-5.6627,5.6627-5.6627,3.1199,0,5.6627,2.5428,5.6627,5.6627,0,1.8665-.9288,3.5256-2.3173,4.5445-.0631-.3787-.1894-.6492-.3877-.9738,1.0369-.8386,1.7042-2.137,1.7042-3.5707,0-2.5698-2.0919-4.6618-4.6618-4.6618ZM16.0447,20.6153c.0541-.3697.1172-.7664.1623-1.1632,2.8854-1.2173,4.9142-4.0666,4.9142-7.3939,0-4.4544-3.6158-8.0611-8.0612-8.0611-4.4634,0-8.0792,3.6068-8.0792,8.0611,0,3.3273,2.0288,6.1856,4.9233,7.3939.0451.3877.1082.7935.1623,1.1542-3.5437-1.2263-6.0774-4.5806-6.0774-8.5481C3.989,7.0538,8.0467,2.9962,13.0601,2.9962c5.0044,0,9.053,4.0576,9.053,9.062,0,3.9585-2.5427,7.3218-6.0684,8.5571Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.0 KiB |
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Artwork" xmlns="http://www.w3.org/2000/svg" width="113.7899" height="26.164" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 113.7899 26.164">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||||
|
<image width="2048" height="2048" transform="translate(.5 .5) scale(.0123)" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAACAAAAAgACAMAAACFeSMCAAABJlBMVEVHcEy6f+HUq+/ZsvLSsOfgtvnpwv7srv/fp/zSpu7XnPjJnObMkO/BgubBiOG8euKiWdP1t/+2a971z/+xdNrBk+KyZNynbNKkY9GsXtirc8+lWtGlVdClVNClbMuiUs6ocM7/3v+gUMy8hNy5gdm3j9K6k9S1jNC0etudTsqyic+UU8avhsyze9S2ftaudtGaTMjXwueweNKsg8ji0e+VW8Cof8SWSsWYXsL06vrNrOKTR8P78f7OtuHTvOWbYsWke8HKqN/HpN2PRMCeZcbEoNrAnNj9+f7////u5Pbp3fG9mNaLQb3byemhacmHPruJR76NUrqFPLmCOreBObaSWL6AOLV9OLR+N7R8NrOARqx7NLKGTLJ5M7B3Mq92Ma5xLKloI6CXA+vBAAAAEXRSTlMAKVue////////////////fYl6K8AAB4R5SURBVHgB7JcHkuswDEN305uj5Of+d/2E3xAzHkXe3oVGijkAnIeOji/DY2AxYglWy7Ww+SisSUKSpyLmiWziXbH1JNCOjMAzzEEKTI8a+0nYCDV4kA67fQTUExKW/WIcGSeJ0aZlvy8GKcc5oslhOCuNZVkGLovLIvA44uG3o6Ojo7c8Ff/+/b62rQkzvOZBrEWwXLXLWg1+sb0g3C51VsvVrm0XqhlayX5u/TR5R0g5IoN7jTU33kx2jEOEyUhGze8o97Sk4EWyIVvDL7ajtqPU5CmUIWqGPDgmdZJkb8CHV+AcHkzImOcgnzKgUMrQRFn+y8+Dn/Nx0NHR0ZE9L9xp+ZsU8QLM/TVHXNtUUONX2e3PVT8QdXl7qSr+WXC1m36qrtEzuKpeMmOsbQRirhlc93nNMgd77LN0l+Mfe3c9Q1dzp9RPdL5dFz5Vn0PUzmuC04THMKLgoTkeT7Kgw1EJfHwRhoqEZ4sSAfx8Kcq9U9GnweV7fBh0dHT0qq+b/iaLN1/QS1tfbNQ5XQ5ZpNjChIxybCTgHzg3cJXmqt0b6U1K5rVR9jR6SP/neVRWRoiaFLc2BtbYR/J7ME+ymBIlrznq1j9IWkw/CCQTTOyk8WUlC+JGtWseT7aupwhoZfsLdcWj12KQkj7iquarAfzIBGzvgRIcSokwSmBZLuOHwQd+FnR0dPSyd9ffkgG98qLl5U1vIpg9737/z97Z6LaNJGHwgHXOULSmg9zm/V/1pK8Ghc6kY+pkytnNTXdP/5FeLH5LPRwywn3ekXefXn/nA/f+iryvK8GR3gfyV1cUR2j4nvAcVsN6tuldp4zzp+S2HewFPTg3ogRrExtjlieC82Hi3gpDSHESH9RraiX/uFR2683mqT4XvYcbFHp0zN5J/RQ4mliv4t1A/1AJ6xNQelL/JXZxeCIL+ePrly9f1m+CO2XJksX7hvbu4t/1mL5j/WQiXpBTGorY3hdIXWt7zurmIj0roRvgG6g/W7guOgKNMrY7yRP0rjROwP87tFeOq5pFpT2D/TzGq7YkO45AeaZ2qJfyBoyLzPWF9zL/EsxoxrMkPKnTPe7dslXsQ33LXjVE/Ns8kPyvrRJGdRXKEUmIrtQvyRT2CdYvgiVLlkzIn4EfcbK/V4S9HpX89eG7LDc1uZXwmtEWEbLTd1ce69D+fFld38VVa/zz6RKlPTv4yMz3UyLDPo1JCSfThu2fLfM8HotOLknbcZmaQXQNJcd5y9i4H8gP44W8jHeJ+liVWh9G/M1z+sVQbNZhFfX2j5zwVchts9NISYx3yh9f/u9/DyxZspgf0v+HgCqm+9K+IhfVQXMuke4z3dROVXEuxgm17M/RG5/pmqn2f1D36t2fD8JlPB2yE3r9uxPNhvAifNaaUjjFewhPoDeqCXaLiJWTvWhXi2Wul/+yHs5X1rNxzzV38aeN+/SR80Hk3zSQnwXrKXZUvrvNjz9CXrPU3UHfpdC3e6zkeOH6MfDby5JF/U8O+Y75kv4e4GuO+dYd6ee6Ml6uU/azuWSOkcNvqtFyAfNcPVkb5fXwn1zpe83o3nzcJY+j96Y2x+0shHsCpjKQ+0hei9ptHtZrZ0/jab1ylQDT46MA3WneiC/BOV+oc8H8KNlUec/Ev0/8AnnFOf8Q+WG4t6xpC3R6XvwQ+VrStTPw28iSJcz6GfUr9N+B/MhIh7N7y/69tJfzzPDPT6Ic3Ap7WR7Anz5lZa4/2R6xvg5PQ2aLewd26X2S6S6o/UR8wnuEftLYjp5CeBnfazl2p9rLsoWQAXjZfmrHeCPpyGU9ZX0w766+Da5RSH/ySIJydr1LNvykYX2iStdy1kr34zf3XzGRXwoTLEvS6z9cvrpsjNzWX2tf4J8rSxb3/12xX8Vqn/eV+Mntyfld2j+rZZv+qUzsob6PzoP4AXsuO57Xydzz8anoSW5TboPudOjtQfsGsqsD7bn9sxadKG6mYnOtUBSci/Kq/fV54Af1rLB84Jz0kqUQ8xfhcpyMNyo2DpnqcSqUTyAR9p2aCHiBb7D7fuIPZ2IB1rVZbH0A4fGz2eauGk1opV6/BP4ZsmRt89dxv5Le4qYhn5iUomP83HCoryfpr+HJR+8jOss/O6V7OB5yO5ufrE+qGqyTTeTu1Ht/do/m6Xmn96BcwuOqahZkhiwty96Zos7pagP09roKw53b8ajH8y4h3jvIc0dy20Wszw/Yw1djqsDvaW82Ed/M9CDqq6XAOd5/HOLluxVQR0vRGtFk+pNSXTO6/BL4199LlixZAvjnXf79r/AIejlP1kDecv7wTaF9PVznFr7P3p/dp6cdgOMd2ikmxTotbqa35mg+7nM7fp7V1c/2LFuHoZohmdhmY75Xkzfv4fKfw7kpD8MTfIs+0Qfy0VEUtKcX4Qbzw0b7iebq3HC2R0lVm9e1XdOG8XrCoQf1cURylkFv9sChXk2lr1AfLY2g3arYKx5NkR8Cf48dgSVL1sjvxD9/effGUT/mtN+/eP9cHKvC/ruD9LRh+zUhJ/HJ+8ln7QP4mK5EXUoX9WcG9c94TY0acB3CVa+UpZ39W7iu3aifq3deV8824vtX6AbspfuINJIX1iMmR/O9ypY1q4l605f0t++G+s18DPME08ef1NcexXgBP8/nqn2F1mTqG1i/X/2Pv6b6ujYEliz5JSL5nfgvVbQnf0H+MPI3xPm+An/APbz3HD7H8iT9iQjo4+MmopPoRPxPt9pxWJxdVbMxPEEDwRPKCXepoVW5Ph6y+7Vbye4bcymE+bgeR0GPjqLYm9hu68jjeCqGU998VL/lQqb5+IQ4uW7YiMrRr+FrOMLjKF9RjVML072D1PUdh3vO74HeRJ3TqVW8F6wjH/ZgYMmSJWW33zkf8iuK4MfB/h3is6XPyXzGeol/0aBe8AP54eqj+sJ4Aa9i/axeWa1a484jBdrlozXi/L1q0rLczI7BY/PTgt2euktFoS8tYZ4MR1BEutffK9/Mtp+pjO+1Un7bLuvl4kW9PtGJHm+yHbyV/9pM9ybN+fxj8S+3C3/tto/vvdeEeLv2bTGOI7ccXifbyawJNv3fHL8D1s+AR8qSdbTfoR/ou3427hdnVDyjL/J9sc6tfUtfhhfxhkZlPtmPu/NY1dE6n+7itMkxWr9tM8P8oqZXcxyX8z+wn/XZ2yrge/HiEVRXtmq9YrPae5lUmm+h98WiQt06jceLrJ/P582zvYli514Rh2qarakxVqtc3FeBTm5RpM74mo6QzNLKmkDJ8uL6GfAwWbLYX6f+duIX/c3QX+W5kr98XCfEP10dpUfyn/DEZrAnkCazaGCPK5+naaB+u55N52FcfmvdXD7QLLMjNstM7tKD9OtKTm+Q3XSa0/X381xX4paFvaEmjXoUL467Q2/9BsGZ6odea3zU3wHkVaxNj5DXqrIek/dmR3FemVBuU+63iv1cO7KrmOTt8Dxy7yzay6vBRBH0TdeUijy6fgY8RpYs9Dfwl/wYqxGZH/VEPi/kkTw54XdH8ksC5PV+g/77t9wo+5fb9rWiO0o2AkkBOCk9m8VUa5+wS+foPq/vfcj+rculOnmMQNzeg3W1a7Cc4jdi+Dw6F0fOAL/hcg3bPmSi/4Hy+heLh6Femcd7Slft3rVnb9RsKvUSSjrDuJfXzrxk5Z0d9Q3eJPqtzNa7AkuWvJ/9nu3DevQbeu4/M+z7sZ2UfiXvx9ft3cfHyXp43w300bPMP+0T/k8pL9UxxHIckpPacnu87OZdLDvaPqVtG01aftuqXZszyRXG9G/XcAO0bwc75kKC89hAdvQacXDd5/PTE3qukxoeIQJexUn3eGXCvuEYKTQ3qDTuR7yGGPFGvIuAilsT7yex7/pF8hq771fAkiWL/dM5P7mvdMf6q4j9p4sN6Dv5Q3q39yvzDX6afn67XfqDevykPe8dwg1BufjWUOV8+KF2Yb1/S/RqBofzqwD0HdVKQ23bLyn9O+QFrJNc3YD22HZPSLRB4mV3769G8iHyOqyq2KYqZfPI3lI5FPcq5sBv7IDeK9YMyl6T5kZv1ZsZ5LqZ4tW/raz3BJYs+R/3/Nnsxxry74L/k9j3H6M/Oe+j4h43kI9KfeuM+fukZ7CfHpnXTfeEtHTGm5BtMCoCeu5icBuqbxQk2+2qM9lR3SC5SRXpHk2RiPmlWzuk5InxZr1sD57j8TPgxTddxY6VzD9OCs1ntb/HeBNLk373vN6GmCk2LHGWv52scwE7smS94Qf73xz64xSx78DvWf4UyfzkThSL9G/au42/M9qDezFfTtNN9D/fN8A7fG+C2pH7RlQ3lWFO9nUbHmdiUKQ4QeUzd0kucQQkl1OLdYNCYcfc5EGz+5vzO0LTNfcUq8OlJbnH8rTbtSbifh7pWd4Line truncated
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M49.6308,11.8432c2.4126,0,4.0928,1.6631,4.0928,4.0843,0,2.4299-1.7146,4.1016-4.1532,4.1016h-2.6713v4.2481h-1.9302v-12.434h4.6617ZM46.8992,18.4092h2.2145c1.6803,0,2.6367-.9047,2.6367-2.473s-.9563-2.4643-2.6281-2.4643h-2.2232v4.9373Z"/>
|
||||||
|
<path d="M54.0083,19.7448c0-2.9124,1.7147-4.6962,4.2998-4.6962s4.2998,1.7837,4.2998,4.6962c0,2.9211-1.7061,4.7048-4.2998,4.7048s-4.2998-1.7837-4.2998-4.7048ZM60.7121,19.7448c0-2.0076-.9479-3.1622-2.404-3.1622s-2.4042,1.1546-2.4042,3.1622c0,2.0164.9479,3.171,2.4042,3.171,1.4648,0,2.404-1.1546,2.404-3.171Z"/>
|
||||||
|
<path d="M63.643,19.7448c0-2.8607,1.5165-4.6789,3.7913-4.6789,1.3011,0,2.3352.6549,2.8435,1.6544h.0346v-4.8772h1.8698v12.434h-1.8095v-1.5424h-.0346c-.5342,1.034-1.5682,1.6975-2.8865,1.6975-2.2921,0-3.8086-1.8268-3.8086-4.6875ZM65.5472,19.7448c0,1.9129.9219,3.1107,2.3955,3.1107,1.4304,0,2.3867-1.215,2.3867-3.1107,0-1.8785-.9563-3.1105-2.3867-3.1105-1.4736,0-2.3955,1.2063-2.3955,3.1105Z"/>
|
||||||
|
<path d="M79.8816,18.3317c-.1895-.9652-.9133-1.732-2.1283-1.732-1.439,0-2.3869,1.1977-2.3869,3.1451,0,1.9905.9565,3.1451,2.4042,3.1451,1.146,0,1.8956-.6289,2.111-1.6802h1.7924c-.1983,1.9302-1.7407,3.2399-3.9206,3.2399-2.5936,0-4.2825-1.7751-4.2825-4.7048,0-2.878,1.6888-4.6962,4.2652-4.6962,2.3352,0,3.7569,1.4993,3.9206,3.2831h-1.7751Z"/>
|
||||||
|
<path d="M82.6315,21.7009c0-1.5856,1.215-2.5592,3.3692-2.6799l2.4816-.1464v-.6979c0-1.0082-.6808-1.6114-1.8181-1.6114-1.0772,0-1.7493.5169-1.913,1.327h-1.7578c.1034-1.6373,1.4993-2.8436,3.7396-2.8436,2.1974,0,3.6019,1.1633,3.6019,2.9814v6.2472h-1.7837v-1.4907h-.043c-.5255,1.0082-1.6715,1.6458-2.8607,1.6458-1.7751,0-3.0158-1.1029-3.0158-2.7314ZM88.4823,20.8823v-.7152l-2.2318.1378c-1.1116.0776-1.7405.5688-1.7405,1.3443,0,.7926.6549,1.3097,1.6544,1.3097,1.3011,0,2.3179-.8962,2.3179-2.0766Z"/>
|
||||||
|
<path d="M95.3657,15.0486c2.1111,0,3.4812,1.1719,3.5415,2.8436h-1.7493c-.1034-.8789-.7496-1.4304-1.7922-1.4304-1.0082,0-1.6803.4911-1.6803,1.215,0,.5686.4137.9563,1.396,1.1804l1.4734.3361c1.8527.4308,2.5592,1.1546,2.5592,2.4643,0,1.6544-1.5424,2.7919-3.6793,2.7919-2.2662,0-3.6278-1.1116-3.8001-2.835h1.8441c.1724.9479.8186,1.4133,1.9561,1.4133,1.1201,0,1.8095-.4654,1.8095-1.2063,0-.586-.3361-.9308-1.327-1.1548l-1.4734-.3445c-1.7234-.3964-2.5765-1.2236-2.5765-2.542,0-1.62,1.4133-2.7316,3.4985-2.7316Z"/>
|
||||||
|
<path d="M103.0107,13.0668v2.1456h1.7232v1.4736h-1.7232v4.9977c0,.7842.3445,1.1373,1.1029,1.1373.1895,0,.4911-.0171.6116-.043v1.4734c-.2068.0519-.6203.0863-1.034.0863-1.8354,0-2.5506-.6979-2.5506-2.4472v-5.2044h-1.3183v-1.4736h1.3183v-2.1456h1.87Z"/>
|
||||||
|
<path d="M109.5416,15.0486c2.1111,0,3.4812,1.1719,3.5415,2.8436h-1.7493c-.1034-.8789-.7496-1.4304-1.7922-1.4304-1.0082,0-1.6803.4911-1.6803,1.215,0,.5686.4137.9563,1.396,1.1804l1.4734.3361c1.8527.4308,2.5592,1.1546,2.5592,2.4643,0,1.6544-1.5424,2.7919-3.6793,2.7919-2.2662,0-3.6278-1.1116-3.8001-2.835h1.8441c.1724.9479.8186,1.4133,1.9561,1.4133,1.1201,0,1.8095-.4654,1.8095-1.2063,0-.586-.3361-.9308-1.327-1.1548l-1.4734-.3445c-1.7234-.3964-2.5765-1.2236-2.5765-2.542,0-1.62,1.4133-2.7316,3.4985-2.7316Z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M39.741,14.162c-1.1094-.0821-2.0505.6196-2.5756.6196-.5327,0-1.3541-.602-2.2251-.5855-1.1436.0168-2.1976.6654-2.7873,1.6894-1.1878,2.0617-.3032,5.1168.8538,6.7898.5659.8175,1.2409,1.7387,2.1275,1.7052.8534-.0336,1.1762-.552,2.2078-.552,1.0309,0,1.3217.552,2.2242.5354.9185-.0171,1.5005-.8344,2.0623-1.6564.6501-.9489.9172-1.8676.9331-1.9162-.0203-.0077-1.7905-.6869-1.8082-2.7263-.016-1.7051,1.3918-2.5247,1.4558-2.5641-.7991-1.1769-2.0334-1.3067-2.4682-1.3389Z"/>
|
||||||
|
<path d="M39.0565,13.0576c.4712-.5694.7882-1.3624.7009-2.1512-.6781.0269-1.4983.4517-1.9846,1.0208-.4356.5041-.8181,1.3115-.7142,2.0841.7558.0584,1.5272-.3839,1.998-.9537Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M32.3404,1.8525h.9297v5.1875h2.8057v.8018h-3.7354V1.8525Z"/>
|
||||||
|
<path d="M37.1695,1.9687c0-.3115.2534-.5645.5645-.5645.3076,0,.5645.2529.5645.5645,0,.3066-.2568.5605-.5645.5605-.311,0-.5645-.2539-.5645-.5605ZM37.2818,3.3339h.8965v4.5078h-.8965V3.3339Z"/>
|
||||||
|
<path d="M39.3887,6.663v-.0049h.9009l.0039.0049c.1123.3398.4399.5684.9756.5684.5557,0,.9419-.2529.9419-.623v-.0078c0-.2822-.2114-.4736-.7349-.5938l-.7178-.166c-.8506-.1953-1.2451-.5811-1.2451-1.2412v-.0039c0-.7842.7305-1.3486,1.7515-1.3486,1.0293,0,1.689.5146,1.7681,1.249v.0039h-.8594v-.0078c-.0869-.3154-.4062-.5518-.9131-.5518-.4937,0-.8506.2441-.8506.6055v.0088c0,.2822.2075.4561.7139.5762l.7139.1621c.8672.2031,1.2822.5732,1.2822,1.2246v.0078c0,.835-.7969,1.4033-1.8672,1.4033-1.0879,0-1.7764-.5107-1.8638-1.2656Z"/>
|
||||||
|
<path d="M44.5836,6.6543v-2.6104h-.7051v-.71h.7051v-1.1367h.9175v1.1367h.9673v.71h-.9673v2.3945c0,.4854.1826.6846.6265.6846.1372,0,.2158-.0039.3408-.0166v.7188c-.1455.0244-.3115.0449-.4858.0449-.9917,0-1.3989-.3486-1.3989-1.2158Z"/>
|
||||||
|
<path d="M47.3658,5.6005v-.0049c0-1.4062.8135-2.3486,2.0713-2.3486s2.0293.9092,2.0293,2.2617v.3154h-3.1958c.0166.8633.4897,1.3652,1.2329,1.3652.5767,0,.9209-.2861,1.0293-.5186l.0166-.0332.8672-.0039-.0083.0371c-.1494.5938-.772,1.2578-1.9258,1.2578-1.3198,0-2.1167-.9004-2.1167-2.3281ZM48.2828,5.1806h2.291c-.0703-.8047-.5225-1.1953-1.1328-1.1953-.6143,0-1.0791.4199-1.1582,1.1953Z"/>
|
||||||
|
<path d="M52.6553,3.3339h.9009v.6846h.0664c.2197-.4814.6514-.7715,1.3071-.7715,1.0083,0,1.5649.6016,1.5649,1.6768v2.918h-.8965v-2.7061c0-.7471-.3115-1.1172-.9717-1.1172-.6597,0-1.0703.4531-1.0703,1.1787v2.6445h-.9009V3.3339Z"/>
|
||||||
|
<path d="M60.0855,5.5918v-.0078c0-1.4492.8135-2.3369,2.1333-2.3369,1.3159,0,2.1294.8838,2.1294,2.3369v.0078c0,1.4531-.8096,2.3369-2.1294,2.3369-1.3237,0-2.1333-.8838-2.1333-2.3369ZM63.4307,5.5918v-.0078c0-1.001-.4399-1.5859-1.2119-1.5859-.7759,0-1.2119.585-1.2119,1.5859v.0078c0,1,.436,1.5859,1.2119,1.5859.772,0,1.2119-.5898,1.2119-1.5859Z"/>
|
||||||
|
<path d="M65.5367,3.3339h.9004v.6846h.0664c.2202-.4814.6519-.7715,1.3076-.7715,1.0088,0,1.5645.6016,1.5645,1.6768v2.918h-.8965v-2.7061c0-.7471-.311-1.1172-.9707-1.1172-.6602,0-1.0713.4531-1.0713,1.1787v2.6445h-.9004V3.3339Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 648 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Artwork" xmlns="http://www.w3.org/2000/svg" width="113.7899" height="26.164" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 113.7899 26.164">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<image width="2048" height="2048" transform="translate(.5 .5) scale(.0123)" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAACAAAAAgACAMAAACFeSMCAAABJlBMVEVHcEy6f+HUq+/ZsvLSsOfgtvnpwv7srv/fp/zSpu7XnPjJnObMkO/BgubBiOG8euKiWdP1t/+2a971z/+xdNrBk+KyZNynbNKkY9GsXtirc8+lWtGlVdClVNClbMuiUs6ocM7/3v+gUMy8hNy5gdm3j9K6k9S1jNC0etudTsqyic+UU8avhsyze9S2ftaudtGaTMjXwueweNKsg8ji0e+VW8Cof8SWSsWYXsL06vrNrOKTR8P78f7OtuHTvOWbYsWke8HKqN/HpN2PRMCeZcbEoNrAnNj9+f7////u5Pbp3fG9mNaLQb3byemhacmHPruJR76NUrqFPLmCOreBObaSWL6AOLV9OLR+N7R8NrOARqx7NLKGTLJ5M7B3Mq92Ma5xLKloI6CXA+vBAAAAEXRSTlMAKVue////////////////fYl6K8AAB4R5SURBVHgB7JcHkuswDEN305uj5Of+d/2E3xAzHkXe3oVGijkAnIeOji/DY2AxYglWy7Ww+SisSUKSpyLmiWziXbH1JNCOjMAzzEEKTI8a+0nYCDV4kA67fQTUExKW/WIcGSeJ0aZlvy8GKcc5oslhOCuNZVkGLovLIvA44uG3o6Ojo7c8Ff/+/b62rQkzvOZBrEWwXLXLWg1+sb0g3C51VsvVrm0XqhlayX5u/TR5R0g5IoN7jTU33kx2jEOEyUhGze8o97Sk4EWyIVvDL7ajtqPU5CmUIWqGPDgmdZJkb8CHV+AcHkzImOcgnzKgUMrQRFn+y8+Dn/Nx0NHR0ZE9L9xp+ZsU8QLM/TVHXNtUUONX2e3PVT8QdXl7qSr+WXC1m36qrtEzuKpeMmOsbQRirhlc93nNMgd77LN0l+Mfe3c9Q1dzp9RPdL5dFz5Vn0PUzmuC04THMKLgoTkeT7Kgw1EJfHwRhoqEZ4sSAfx8Kcq9U9GnweV7fBh0dHT0qq+b/iaLN1/QS1tfbNQ5XQ5ZpNjChIxybCTgHzg3cJXmqt0b6U1K5rVR9jR6SP/neVRWRoiaFLc2BtbYR/J7ME+ymBIlrznq1j9IWkw/CCQTTOyk8WUlC+JGtWseT7aupwhoZfsLdcWj12KQkj7iquarAfzIBGzvgRIcSokwSmBZLuOHwQd+FnR0dPSyd9ffkgG98qLl5U1vIpg9737/z97Z6LaNJGHwgHXOULSmg9zm/V/1pK8Ghc6kY+pkytnNTXdP/5FeLH5LPRwywn3ekXefXn/nA/f+iryvK8GR3gfyV1cUR2j4nvAcVsN6tuldp4zzp+S2HewFPTg3ogRrExtjlieC82Hi3gpDSHESH9RraiX/uFR2683mqT4XvYcbFHp0zN5J/RQ4mliv4t1A/1AJ6xNQelL/JXZxeCIL+ePrly9f1m+CO2XJksX7hvbu4t/1mL5j/WQiXpBTGorY3hdIXWt7zurmIj0roRvgG6g/W7guOgKNMrY7yRP0rjROwP87tFeOq5pFpT2D/TzGq7YkO45AeaZ2qJfyBoyLzPWF9zL/EsxoxrMkPKnTPe7dslXsQ33LXjVE/Ns8kPyvrRJGdRXKEUmIrtQvyRT2CdYvgiVLlkzIn4EfcbK/V4S9HpX89eG7LDc1uZXwmtEWEbLTd1ce69D+fFld38VVa/zz6RKlPTv4yMz3UyLDPo1JCSfThu2fLfM8HotOLknbcZmaQXQNJcd5y9i4H8gP44W8jHeJ+liVWh9G/M1z+sVQbNZhFfX2j5zwVchts9NISYx3yh9f/u9/DyxZspgf0v+HgCqm+9K+IhfVQXMuke4z3dROVXEuxgm17M/RG5/pmqn2f1D36t2fD8JlPB2yE3r9uxPNhvAifNaaUjjFewhPoDeqCXaLiJWTvWhXi2Wul/+yHs5X1rNxzzV38aeN+/SR80Hk3zSQnwXrKXZUvrvNjz9CXrPU3UHfpdC3e6zkeOH6MfDby5JF/U8O+Y75kv4e4GuO+dYd6ee6Ml6uU/azuWSOkcNvqtFyAfNcPVkb5fXwn1zpe83o3nzcJY+j96Y2x+0shHsCpjKQ+0hei9ptHtZrZ0/jab1ylQDT46MA3WneiC/BOV+oc8H8KNlUec/Ev0/8AnnFOf8Q+WG4t6xpC3R6XvwQ+VrStTPw28iSJcz6GfUr9N+B/MhIh7N7y/69tJfzzPDPT6Ic3Ap7WR7Anz5lZa4/2R6xvg5PQ2aLewd26X2S6S6o/UR8wnuEftLYjp5CeBnfazl2p9rLsoWQAXjZfmrHeCPpyGU9ZX0w766+Da5RSH/ySIJydr1LNvykYX2iStdy1kr34zf3XzGRXwoTLEvS6z9cvrpsjNzWX2tf4J8rSxb3/12xX8Vqn/eV+Mntyfld2j+rZZv+qUzsob6PzoP4AXsuO57Xydzz8anoSW5TboPudOjtQfsGsqsD7bn9sxadKG6mYnOtUBSci/Kq/fV54Af1rLB84Jz0kqUQ8xfhcpyMNyo2DpnqcSqUTyAR9p2aCHiBb7D7fuIPZ2IB1rVZbH0A4fGz2eauGk1opV6/BP4ZsmRt89dxv5Le4qYhn5iUomP83HCoryfpr+HJR+8jOss/O6V7OB5yO5ufrE+qGqyTTeTu1Ht/do/m6Xmn96BcwuOqahZkhiwty96Zos7pagP09roKw53b8ajH8y4h3jvIc0dy20Wszw/Yw1djqsDvaW82Ed/M9CDqq6XAOd5/HOLluxVQR0vRGtFk+pNSXTO6/BL4199LlixZAvjnXf79r/AIejlP1kDecv7wTaF9PVznFr7P3p/dp6cdgOMd2ikmxTotbqa35mg+7nM7fp7V1c/2LFuHoZohmdhmY75Xkzfv4fKfw7kpD8MTfIs+0Qfy0VEUtKcX4Qbzw0b7iebq3HC2R0lVm9e1XdOG8XrCoQf1cURylkFv9sChXk2lr1AfLY2g3arYKx5NkR8Cf48dgSVL1sjvxD9/effGUT/mtN+/eP9cHKvC/ruD9LRh+zUhJ/HJ+8ln7QP4mK5EXUoX9WcG9c94TY0acB3CVa+UpZ39W7iu3aifq3deV8824vtX6AbspfuINJIX1iMmR/O9ypY1q4l605f0t++G+s18DPME08ef1NcexXgBP8/nqn2F1mTqG1i/X/2Pv6b6ujYEliz5JSL5nfgvVbQnf0H+MPI3xPm+An/APbz3HD7H8iT9iQjo4+MmopPoRPxPt9pxWJxdVbMxPEEDwRPKCXepoVW5Ph6y+7Vbye4bcymE+bgeR0GPjqLYm9hu68jjeCqGU998VL/lQqb5+IQ4uW7YiMrRr+FrOMLjKF9RjVML072D1PUdh3vO74HeRJ3TqVW8F6wjH/ZgYMmSJWW33zkf8iuK4MfB/h3is6XPyXzGeol/0aBe8AP54eqj+sJ4Aa9i/axeWa1a484jBdrlozXi/L1q0rLczI7BY/PTgt2euktFoS8tYZ4MR1BEutffK9/Mtp+pjO+1Un7bLuvl4kW9PtGJHm+yHbyV/9pM9ybN+fxj8S+3C3/tto/vvdeEeLv2bTGOI7ccXifbyawJNv3fHL8D1s+AR8qSdbTfoR/ou3427hdnVDyjL/J9sc6tfUtfhhfxhkZlPtmPu/NY1dE6n+7itMkxWr9tM8P8oqZXcxyX8z+wn/XZ2yrge/HiEVRXtmq9YrPae5lUmm+h98WiQt06jceLrJ/P582zvYli514Rh2qarakxVqtc3FeBTm5RpM74mo6QzNLKmkDJ8uL6GfAwWbLYX6f+duIX/c3QX+W5kr98XCfEP10dpUfyn/DEZrAnkCazaGCPK5+naaB+u55N52FcfmvdXD7QLLMjNstM7tKD9OtKTm+Q3XSa0/X381xX4paFvaEmjXoUL467Q2/9BsGZ6odea3zU3wHkVaxNj5DXqrIek/dmR3FemVBuU+63iv1cO7KrmOTt8Dxy7yzay6vBRBH0TdeUijy6fgY8RpYs9Dfwl/wYqxGZH/VEPi/kkTw54XdH8ksC5PV+g/77t9wo+5fb9rWiO0o2AkkBOCk9m8VUa5+wS+foPq/vfcj+rculOnmMQNzeg3W1a7Cc4jdi+Dw6F0fOAL/hcg3bPmSi/4Hy+heLh6Femcd7Slft3rVnb9RsKvUSSjrDuJfXzrxk5Z0d9Q3eJPqtzNa7AkuWvJ/9nu3DevQbeu4/M+z7sZ2UfiXvx9ft3cfHyXp43w300bPMP+0T/k8pL9UxxHIckpPacnu87OZdLDvaPqVtG01aftuqXZszyRXG9G/XcAO0bwc75kKC89hAdvQacXDd5/PTE3qukxoeIQJexUn3eGXCvuEYKTQ3qDTuR7yGGPFGvIuAilsT7yex7/pF8hq771fAkiWL/dM5P7mvdMf6q4j9p4sN6Dv5Q3q39yvzDX6afn67XfqDevykPe8dwg1BufjWUOV8+KF2Yb1/S/RqBofzqwD0HdVKQ23bLyn9O+QFrJNc3YD22HZPSLRB4mV3769G8iHyOqyq2KYqZfPI3lI5FPcq5sBv7IDeK9YMyl6T5kZv1ZsZ5LqZ4tW/raz3BJYs+R/3/Nnsxxry74L/k9j3H6M/Oe+j4h43kI9KfeuM+fukZ7CfHpnXTfeEtHTGm5BtMCoCeu5icBuqbxQk2+2qM9lR3SC5SRXpHk2RiPmlWzuk5InxZr1sD57j8TPgxTddxY6VzD9OCs1ntb/HeBNLk373vN6GmCk2LHGWv52scwE7smS94Qf73xz64xSx78DvWf4UyfzkThSL9G/au42/M9qDezFfTtNN9D/fN8A7fG+C2pH7RlQ3lWFO9nUbHmdiUKQ4QeUzd0kucQQkl1OLdYNCYcfc5EGz+5vzO0LTNfcUq8OlJbnH8rTbtSbifh7pWd4Line truncated
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M49.6308,11.8432c2.4126,0,4.0928,1.6631,4.0928,4.0843,0,2.4299-1.7146,4.1016-4.1532,4.1016h-2.6713v4.2481h-1.9302v-12.434h4.6617ZM46.8992,18.4092h2.2145c1.6803,0,2.6367-.9047,2.6367-2.473s-.9563-2.4643-2.6281-2.4643h-2.2232v4.9373Z"/>
|
||||||
|
<path class="st0" d="M54.0083,19.7448c0-2.9124,1.7147-4.6962,4.2998-4.6962s4.2998,1.7837,4.2998,4.6962c0,2.9211-1.7061,4.7048-4.2998,4.7048s-4.2998-1.7837-4.2998-4.7048ZM60.7121,19.7448c0-2.0076-.9479-3.1622-2.404-3.1622s-2.4042,1.1546-2.4042,3.1622c0,2.0164.9479,3.171,2.4042,3.171,1.4648,0,2.404-1.1546,2.404-3.171Z"/>
|
||||||
|
<path class="st0" d="M63.643,19.7448c0-2.8607,1.5165-4.6789,3.7913-4.6789,1.3011,0,2.3352.6549,2.8435,1.6544h.0346v-4.8772h1.8698v12.434h-1.8095v-1.5424h-.0346c-.5342,1.034-1.5682,1.6975-2.8865,1.6975-2.2921,0-3.8086-1.8268-3.8086-4.6875ZM65.5472,19.7448c0,1.9129.9219,3.1107,2.3955,3.1107,1.4304,0,2.3867-1.215,2.3867-3.1107,0-1.8785-.9563-3.1105-2.3867-3.1105-1.4736,0-2.3955,1.2063-2.3955,3.1105Z"/>
|
||||||
|
<path class="st0" d="M79.8816,18.3317c-.1895-.9652-.9133-1.732-2.1283-1.732-1.439,0-2.3869,1.1977-2.3869,3.1451,0,1.9905.9565,3.1451,2.4042,3.1451,1.146,0,1.8956-.6289,2.111-1.6802h1.7924c-.1983,1.9302-1.7407,3.2399-3.9206,3.2399-2.5936,0-4.2825-1.7751-4.2825-4.7048,0-2.878,1.6888-4.6962,4.2652-4.6962,2.3352,0,3.7569,1.4993,3.9206,3.2831h-1.7751Z"/>
|
||||||
|
<path class="st0" d="M82.6315,21.7009c0-1.5856,1.215-2.5592,3.3692-2.6799l2.4816-.1464v-.6979c0-1.0082-.6808-1.6114-1.8181-1.6114-1.0772,0-1.7493.5169-1.913,1.327h-1.7578c.1034-1.6373,1.4993-2.8436,3.7396-2.8436,2.1974,0,3.6019,1.1633,3.6019,2.9814v6.2472h-1.7837v-1.4907h-.043c-.5255,1.0082-1.6715,1.6458-2.8607,1.6458-1.7751,0-3.0158-1.1029-3.0158-2.7314ZM88.4823,20.8823v-.7152l-2.2318.1378c-1.1116.0776-1.7405.5688-1.7405,1.3443,0,.7926.6549,1.3097,1.6544,1.3097,1.3011,0,2.3179-.8962,2.3179-2.0766Z"/>
|
||||||
|
<path class="st0" d="M95.3657,15.0486c2.1111,0,3.4812,1.1719,3.5415,2.8436h-1.7493c-.1034-.8789-.7496-1.4304-1.7922-1.4304-1.0082,0-1.6803.4911-1.6803,1.215,0,.5686.4137.9563,1.396,1.1804l1.4734.3361c1.8527.4308,2.5592,1.1546,2.5592,2.4643,0,1.6544-1.5424,2.7919-3.6793,2.7919-2.2662,0-3.6278-1.1116-3.8001-2.835h1.8441c.1724.9479.8186,1.4133,1.9561,1.4133,1.1201,0,1.8095-.4654,1.8095-1.2063,0-.586-.3361-.9308-1.327-1.1548l-1.4734-.3445c-1.7234-.3964-2.5765-1.2236-2.5765-2.542,0-1.62,1.4133-2.7316,3.4985-2.7316Z"/>
|
||||||
|
<path class="st0" d="M103.0107,13.0668v2.1456h1.7232v1.4736h-1.7232v4.9977c0,.7842.3445,1.1373,1.1029,1.1373.1895,0,.4911-.0171.6116-.043v1.4734c-.2068.0519-.6203.0863-1.034.0863-1.8354,0-2.5506-.6979-2.5506-2.4472v-5.2044h-1.3183v-1.4736h1.3183v-2.1456h1.87Z"/>
|
||||||
|
<path class="st0" d="M109.5416,15.0486c2.1111,0,3.4812,1.1719,3.5415,2.8436h-1.7493c-.1034-.8789-.7496-1.4304-1.7922-1.4304-1.0082,0-1.6803.4911-1.6803,1.215,0,.5686.4137.9563,1.396,1.1804l1.4734.3361c1.8527.4308,2.5592,1.1546,2.5592,2.4643,0,1.6544-1.5424,2.7919-3.6793,2.7919-2.2662,0-3.6278-1.1116-3.8001-2.835h1.8441c.1724.9479.8186,1.4133,1.9561,1.4133,1.1201,0,1.8095-.4654,1.8095-1.2063,0-.586-.3361-.9308-1.327-1.1548l-1.4734-.3445c-1.7234-.3964-2.5765-1.2236-2.5765-2.542,0-1.62,1.4133-2.7316,3.4985-2.7316Z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M39.741,14.162c-1.1094-.0821-2.0505.6196-2.5756.6196-.5327,0-1.3541-.602-2.2251-.5855-1.1436.0168-2.1976.6654-2.7873,1.6894-1.1878,2.0617-.3032,5.1168.8538,6.7898.5659.8175,1.2409,1.7387,2.1275,1.7052.8534-.0336,1.1762-.552,2.2078-.552,1.0309,0,1.3217.552,2.2242.5354.9185-.0171,1.5005-.8344,2.0623-1.6564.6501-.9489.9172-1.8676.9331-1.9162-.0203-.0077-1.7905-.6869-1.8082-2.7263-.016-1.7051,1.3918-2.5247,1.4558-2.5641-.7991-1.1769-2.0334-1.3067-2.4682-1.3389Z"/>
|
||||||
|
<path class="st0" d="M39.0565,13.0576c.4712-.5694.7882-1.3624.7009-2.1512-.6781.0269-1.4983.4517-1.9846,1.0208-.4356.5041-.8181,1.3115-.7142,2.0841.7558.0584,1.5272-.3839,1.998-.9537Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M32.3404,1.8525h.9297v5.1875h2.8057v.8018h-3.7354V1.8525Z"/>
|
||||||
|
<path class="st0" d="M37.1695,1.9687c0-.3115.2534-.5645.5645-.5645.3076,0,.5645.2529.5645.5645,0,.3066-.2568.5605-.5645.5605-.311,0-.5645-.2539-.5645-.5605ZM37.2818,3.3339h.8965v4.5078h-.8965V3.3339Z"/>
|
||||||
|
<path class="st0" d="M39.3887,6.663v-.0049h.9009l.0039.0049c.1123.3398.4399.5684.9756.5684.5557,0,.9419-.2529.9419-.623v-.0078c0-.2822-.2114-.4736-.7349-.5938l-.7178-.166c-.8506-.1953-1.2451-.5811-1.2451-1.2412v-.0039c0-.7842.7305-1.3486,1.7515-1.3486,1.0293,0,1.689.5146,1.7681,1.249v.0039h-.8594v-.0078c-.0869-.3154-.4062-.5518-.9131-.5518-.4937,0-.8506.2441-.8506.6055v.0088c0,.2822.2075.4561.7139.5762l.7139.1621c.8672.2031,1.2822.5732,1.2822,1.2246v.0078c0,.835-.7969,1.4033-1.8672,1.4033-1.0879,0-1.7764-.5107-1.8638-1.2656Z"/>
|
||||||
|
<path class="st0" d="M44.5836,6.6543v-2.6104h-.7051v-.71h.7051v-1.1367h.9175v1.1367h.9673v.71h-.9673v2.3945c0,.4854.1826.6846.6265.6846.1372,0,.2158-.0039.3408-.0166v.7188c-.1455.0244-.3115.0449-.4858.0449-.9917,0-1.3989-.3486-1.3989-1.2158Z"/>
|
||||||
|
<path class="st0" d="M47.3658,5.6005v-.0049c0-1.4062.8135-2.3486,2.0713-2.3486s2.0293.9092,2.0293,2.2617v.3154h-3.1958c.0166.8633.4897,1.3652,1.2329,1.3652.5767,0,.9209-.2861,1.0293-.5186l.0166-.0332.8672-.0039-.0083.0371c-.1494.5938-.772,1.2578-1.9258,1.2578-1.3198,0-2.1167-.9004-2.1167-2.3281ZM48.2828,5.1806h2.291c-.0703-.8047-.5225-1.1953-1.1328-1.1953-.6143,0-1.0791.4199-1.1582,1.1953Z"/>
|
||||||
|
<path class="st0" d="M52.6553,3.3339h.9009v.6846h.0664c.2197-.4814.6514-.7715,1.3071-.7715,1.0083,0,1.5649.6016,1.5649,1.6768v2.918h-.8965v-2.7061c0-.7471-.3115-1.1172-.9717-1.1172-.6597,0-1.0703.4531-1.0703,1.1787v2.6445h-.9009V3.3339Z"/>
|
||||||
|
<path class="st0" d="M60.0855,5.5918v-.0078c0-1.4492.8135-2.3369,2.1333-2.3369,1.3159,0,2.1294.8838,2.1294,2.3369v.0078c0,1.4531-.8096,2.3369-2.1294,2.3369-1.3237,0-2.1333-.8838-2.1333-2.3369ZM63.4307,5.5918v-.0078c0-1.001-.4399-1.5859-1.2119-1.5859-.7759,0-1.2119.585-1.2119,1.5859v.0078c0,1,.436,1.5859,1.2119,1.5859.772,0,1.2119-.5898,1.2119-1.5859Z"/>
|
||||||
|
<path class="st0" d="M65.5367,3.3339h.9004v.6846h.0664c.2202-.4814.6519-.7715,1.3076-.7715,1.0088,0,1.5645.6016,1.5645,1.6768v2.918h-.8965v-2.7061c0-.7471-.311-1.1172-.9707-1.1172-.6602,0-1.0713.4531-1.0713,1.1787v2.6445h-.9004V3.3339Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 649 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 180 KiB |
@@ -0,0 +1,72 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Verse by Verse with Nate</title>
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Barlow+Condensed:wght@300;500;700&display=swap');
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
body{background:#0a0a0a;display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:'Barlow Condensed',sans-serif;}
|
||||||
|
.card{width:1080px;height:1080px;background:#0d0d0d;position:relative;overflow:hidden;display:flex;flex-direction:column;align-items:center;justify-content:center;}
|
||||||
|
.bg-gradient{position:absolute;inset:0;background:radial-gradient(ellipse at 20% 10%,rgba(180,120,20,.18) 0%,transparent 55%),radial-gradient(ellipse at 80% 90%,rgba(140,80,10,.15) 0%,transparent 50%),linear-gradient(160deg,#0d0d0d 0%,#110e07 50%,#0d0d0d 100%);}
|
||||||
|
.bar-left{position:absolute;left:-30px;top:-80px;width:8px;height:130%;background:linear-gradient(180deg,transparent,#c8860a 30%,#c8860a 70%,transparent);transform:rotate(-4deg);opacity:.7;}
|
||||||
|
.bar-left2{position:absolute;left:20px;top:-80px;width:2px;height:130%;background:linear-gradient(180deg,transparent,#c8860a 30%,#c8860a 70%,transparent);transform:rotate(-4deg);opacity:.35;}
|
||||||
|
.bar-right{position:absolute;right:-30px;top:-80px;width:8px;height:130%;background:linear-gradient(180deg,transparent,#c8860a 30%,#c8860a 70%,transparent);transform:rotate(4deg);opacity:.7;}
|
||||||
|
.bar-right2{position:absolute;right:20px;top:-80px;width:2px;height:130%;background:linear-gradient(180deg,transparent,#c8860a 30%,#c8860a 70%,transparent);transform:rotate(4deg);opacity:.35;}
|
||||||
|
.top-rule{position:absolute;top:100px;left:80px;right:80px;height:1px;background:linear-gradient(90deg,transparent,#c8860a 30%,#c8860a 70%,transparent);opacity:.5;}
|
||||||
|
.bottom-rule{position:absolute;bottom:100px;left:80px;right:80px;height:1px;background:linear-gradient(90deg,transparent,#c8860a 30%,#c8860a 70%,transparent);opacity:.5;}
|
||||||
|
.cross-ornament{position:absolute;top:48px;left:50%;transform:translateX(-50%);color:#c8860a;font-size:22px;letter-spacing:18px;opacity:.7;white-space:nowrap;}
|
||||||
|
.content{position:relative;z-index:10;display:flex;flex-direction:column;align-items:center;width:100%;padding:0 90px;gap:0;}
|
||||||
|
.eyebrow{font-family:'Barlow Condensed',sans-serif;font-weight:300;font-size:22px;letter-spacing:10px;text-transform:uppercase;color:#c8860a;margin-bottom:18px;opacity:.9;}
|
||||||
|
.title-verse{font-family:'Playfair Display',serif;font-weight:900;font-size:118px;line-height:.88;color:#f0e6d0;letter-spacing:-2px;text-shadow:0 0 60px rgba(200,134,10,.25);display:block;text-align:center;}
|
||||||
|
.title-by{font-family:'Barlow Condensed',sans-serif;font-weight:300;font-size:32px;letter-spacing:16px;text-transform:uppercase;color:#c8860a;display:block;margin:4px 0;text-align:center;}
|
||||||
|
.with-nate{font-family:'Barlow Condensed',sans-serif;font-weight:700;font-size:36px;letter-spacing:8px;text-transform:uppercase;color:#c8860a;margin-top:18px;margin-bottom:6px;}
|
||||||
|
.with-nate span{color:#f0e6d0;opacity:.9;}
|
||||||
|
.divider{width:200px;height:2px;background:linear-gradient(90deg,transparent,#c8860a,transparent);margin:22px auto;}
|
||||||
|
.tagline{font-family:'Barlow Condensed',sans-serif;font-weight:300;font-size:24px;letter-spacing:6px;text-transform:uppercase;color:#d4b87a;text-align:center;opacity:.85;margin-bottom:30px;}
|
||||||
|
.qr-frame{position:relative;padding:14px;background:#f5eed8;}
|
||||||
|
.qr-frame::before,.qr-frame::after{content:'';position:absolute;width:28px;height:28px;border-color:#c8860a;border-style:solid;}
|
||||||
|
.qr-frame::before{top:-4px;left:-4px;border-width:3px 0 0 3px;}
|
||||||
|
.qr-frame::after{bottom:-4px;right:-4px;border-width:0 3px 3px 0;}
|
||||||
|
.qr-frame .corner-tr,.qr-frame .corner-bl{position:absolute;width:28px;height:28px;border-color:#c8860a;border-style:solid;}
|
||||||
|
.qr-frame .corner-tr{top:-4px;right:-4px;border-width:3px 3px 0 0;}
|
||||||
|
.qr-frame .corner-bl{bottom:-4px;left:-4px;border-width:0 0 3px 3px;}
|
||||||
|
.qr-frame img{display:block;width:188px;height:188px;image-rendering:pixelated;}
|
||||||
|
.scan-label{font-family:'Barlow Condensed',sans-serif;font-weight:500;font-size:15px;letter-spacing:5px;text-transform:uppercase;color:#c8860a;margin-top:16px;opacity:.8;text-align:center;}
|
||||||
|
.bottom-badge{position:absolute;bottom:44px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:16px;white-space:nowrap;}
|
||||||
|
.dot{width:5px;height:5px;border-radius:50%;background:#c8860a;opacity:.7;}
|
||||||
|
.badge-text{font-family:'Barlow Condensed',sans-serif;font-weight:300;font-size:16px;letter-spacing:8px;text-transform:uppercase;color:#a08040;opacity:.7;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="bg-gradient"></div>
|
||||||
|
<div class="bar-left"></div>
|
||||||
|
<div class="bar-left2"></div>
|
||||||
|
<div class="bar-right"></div>
|
||||||
|
<div class="bar-right2"></div>
|
||||||
|
<div class="top-rule"></div>
|
||||||
|
<div class="bottom-rule"></div>
|
||||||
|
<div class="cross-ornament">✦ ✦ ✦</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="eyebrow">A Journey Through Scripture</div>
|
||||||
|
<span class="title-verse">VERSE</span>
|
||||||
|
<span class="title-by">· by ·</span>
|
||||||
|
<span class="title-verse">VERSE</span>
|
||||||
|
<div class="with-nate"><span>with </span>Nate</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="qr-frame">
|
||||||
|
<div class="corner-tr"></div>
|
||||||
|
<div class="corner-bl"></div>
|
||||||
|
<img src="data:image/png;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCASwBLADASIAAhEBAxEB/8QAHQABAQEBAQEBAQEBAAAAAAAAAAgJBwYEBQMCAf/EAGMQAAEABgIIDgsMCAUEAgICAwABAgMEBQYHEQgJFxghN1aVEjE4V3R1dpSys7TR0tMTFBU2QVRVYXOlsSIlNDVRZ4SRkpPU5BYjMmNxgcHwJjNDRGIkQlOFcqFSZOEngoOi/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/ALLAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGW1L0+Tw60szg7O05zEwYMo6+qM2bOJtlVVFUN10IVQhC1SEIRgqPL3Q5/y4mbOzfpH9qaMcU67oH/lC55ID090Of8ALiZs7N+kLoc/5cTNnZv0jzAA9PdDn/LiZs7N+kLoc/5cTNnZv0jzAA9PdDn/AC4mbOzfpC6HP+XEzZ2b9I8wAPT3Q5/y4mbOzfpC6HP+XEzZ2b9I8wAPT3Q5/wAuJmzs36Quhz/lxM2dm/SPMAD090Of8uJmzs36Quhz/lxM2dm/SPMAD090Of8ALiZs7N+kLoc/5cTNnZv0jzAA9PdDn/LiZs7N+kLoc/5cTNnZv0jzAA9PdDn/AC4mbOzfpC6HP+XEzZ2b9I8wAPT3Q5/y4mbOzfpC6HP+XEzZ2b9I8wAPT3Q5/wAuJmzs36Quhz/lxM2dm/SPMAD090Of8uJmzs36Quhz/lxM2dm/SPMAD090Of8ALiZs7N+kLoc/5cTNnZv0jzAAuW11zBHo85TstHI3Eooli0ckMkvj0u2SzrQ3r0OiSmqupGl8hWZHVrO+AT56Vw9jwWKAAAAAAAABNVsHjkagVGUAbwSLxCGNmkZQou0c3ldissr2FompKVUorRX4CI7oc/5cTNnZv0izLZFirlzbtHENSDAPT3Q5/wAuJmzs36Quhz/lxM2dm/SPMAD090Of8uJmzs36Quhz/lxM2dm/SPMAD090Of8ALiZs7N+kLoc/5cTNnZv0jzAApOwkm+bYvZAQxxi00RuIOi7m9JWYPT+1as1koZJShKVVlkowJNBTN+wO1RsK2E98Uk0gAAADkdmLEYhCrHGan+Fvz04vjLtPsbd2arM2ilb4wQmpZVKEorQlKP4JSZ1XQ5/y4mbOzfpGhdm1qYpu+hctYGZoHp7oc/5cTNnZv0hdDn/LiZs7N+keYAHp7oc/5cTNnZv0hdDn/LiZs7N+keYAHp7oc/5cTNnZv0hdDn/LiZs7N+keYAHp7oc/5cTNnZv0hdDn/LiZs7N+keYAHp7oc/5cTNnZv0hdDn/LiZs7N+keYAHRaOJ9np4pDlt3bznMTZk1izqouzaRRslVdVLZVCUJQlbChKMBqmZE0YorpKldFSE+/DpgTpJ/XKGuwAAAfJGl12cHfWjNZZRdV3aJVWVTUlCUKpwoMmboc/5cTNnZv0jWWO/Ej/sZpwUmOYHp7oc/5cTNnZv0hdDn/LiZs7N+keYAHp7oc/5cTNnZv0hdDn/LiZs7N+keYAHp7oc/5cTNnZv0hdDn/LiZs7N+keYAHp7oc/5cTNnZv0hdDn/LiZs7N+keYAHp7oc/5cTNnZv0hdDn/LiZs7N+keYAHp7oc/5cTNnZv0hdDn/LiZs7N+keYAHp7oc/5cTNnZv0hdDn/LiZs7N+keYAHp7oc/5cTNnZv0hdDn/LiZs7N+keYAHp7oc/5cTNnZv0hdDn/LiZs7N+keYAHp7oc/5cTNnZv0jRWw6iMQitjjKr/FH56fnxr252Ru8tVmjRep8boRWsslKU1IQhH8EIMwzTKwl1MUo/TeWtwOzAAAAAAAAEH2e01zTBabXZzg0yxqGuyYKwXSxdIg0ZKaJLRrWnQqpQhCcCPPgLwM9rYehN3h0wJ+Inf/tq/wBVt9f8f5eADi90Of8ALiZs7N+kLoc/5cTNnZv0jzAA9PdDn/LiZs7N+kW5a+I5Go7RlH28bi8QibZnGUqKNHx5XbLKq9hZpqQlZKakV+Az8LztbuKuY9u08QyAqUAAAAAAAAky2KTBHoC5SStA43EoWls0fUNUub0uxS0qQwq0WhSiuqtOn8pWZHVsx+ASH6V/9juBKd0Of8uJmzs36Quhz/lxM2dm/SPMAD090Of8uJmzs36Quhz/AJcTNnZv0jzAA9PdDn/LiZs7N+kLoc/5cTNnZv0jzAA9PdDn/LiZs7N+kLoc/wCXEzZ2b9I8wAPT3Q5/y4mbOzfpC6HP+XEzZ2b9I8wAPT3Q5/y4mbOzfpC6HP8AlxM2dm/SPMAD090Of8uJmzs36Quhz/lxM2dm/SPMAD090Of8uJmzs36Quhz/AJcTNnZv0jzAA9PdDn/LiZs7N+kLoc/5cTNnZv0jzAA9PdDn/LiZs7N+kLoc/wCXEzZ2b9I8wAPT3Q5/y4mbOzfpC6HP+XEzZ2b9I8wAPT3Q5/y4mbOzfpC6HP8AlxM2dm/SPMAD090Of8uJmzs36Quhz/lxM2dm/SPMAD090Of8uJmzs36R6iiGfJ4eqWZPdnmc5ibsGsdclGjNpE2yyq6qW6iEqpQlapKEowVHMD1tC+OKSt0DhyhQDWsAAAAAAAAAAAAAAAAAAAABkpTRjinXdA/8oXPJHraaMcU67oH/AJQueSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC07Wd8Anz0rh7HgsUjq1nfAJ89K4ex4LFAAAAAAAAAlq2RYq5c27RxDUgwvO2RYq5c27RxDUgwAAAAAAAADvFgdqjYVsJ74pJpAZv2B2qNhWwnvikmkAAAAcZs2tTFN30LlrAzNNMrNrUxTd9C5awMzQAAAAAAAAAAAAAD0VGKK6SpXRUhPvw6YE6Sf1yhrsZE0YorpKldFSE+/DpgTpJ/XKGuwAAAfHHfiR/2M04KTHM2MjvxI/wCxmnBSY5gAAAAAAAAAAAAAAAAAAAAAAAADTKwl1MUo/TeWtzM00ysJdTFKP03lrcDswAAAAAAABnrbDqrvLrVofiJ3rqr/API20/8A+DQoz2th6U3eHTCn4id/+6v/AFW31fw/n4QJwAAAvO1u4q5j27TxDIgwvO1u4q5j27TxDICpQAAAAAAACOrZj8AkP0r/AOx3LFI6tmPwCQ/Sv/sdwIsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9bQvjikrdA4coUPJHraF8cUlboHDlCgGtYAAAAAAAAAAAAAAAAAAAADJSmjHFOu6B/5QueSPW00Y4p13QP/KFzyQAAAaQUAUTUZRehWUIpFJEl99fnqFMWrdu2clFl2i6VcKyyUowpPLine truncated
|
||||||
|
</div>
|
||||||
|
<div class="scan-label">Scan to Listen</div>
|
||||||
|
</div>
|
||||||
|
<div class="bottom-badge">
|
||||||
|
<div class="dot"></div>
|
||||||
|
<div class="badge-text">Scripture · Reflection · Faith</div>
|
||||||
|
<div class="dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,115 +1,258 @@
|
|||||||
# Siteforge
|
# Siteforge
|
||||||
|
|
||||||
This project is a React + TypeScript portfolio app built with Vite.
|
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.
|
||||||
|
|
||||||
It is designed to showcase two categories of work:
|
## Features
|
||||||
|
|
||||||
- Websites
|
- **Podcast episodes** — episode listing, detail pages, downloadable resources, finished series archive
|
||||||
- Apps
|
- **Study system** — per-user enrollment in structured Bible studies with sections, notes, progress tracking, section quizzes, community posts/comments, and completion certificates
|
||||||
|
- **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:
|
---
|
||||||
|
|
||||||
- Dynamic cards generated from a project data source
|
## Tech Stack
|
||||||
- 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
|
|
||||||
|
|
||||||
## Run locally
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| 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
|
||||||
```
|
|
||||||
|
|
||||||
`dev:full` starts both:
|
# Production build
|
||||||
|
|
||||||
- Vite frontend (`http://localhost:5173`)
|
|
||||||
- Admin content API (`http://localhost:4173`)
|
|
||||||
|
|
||||||
## Build for production
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
|
# Start production server (reads .env)
|
||||||
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run in container
|
Create a `.env` file at the project root with the required environment variables before running locally. The server uses `--env-file=.env`.
|
||||||
|
|
||||||
Pull image directly:
|
---
|
||||||
|
|
||||||
```bash
|
## Docker / Deployment
|
||||||
docker pull ghcr.io/nmemmert/siteforge:latest
|
|
||||||
docker run -d --name siteforge-app -p 4173:4173 -v ./data:/app/data ghcr.io/nmemmert/siteforge:latest
|
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.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- /path/to/your/data:/app/data
|
||||||
```
|
```
|
||||||
|
|
||||||
Run with Docker Compose:
|
### Useful commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Pull latest image and restart
|
||||||
docker compose pull
|
docker compose pull
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
|
||||||
|
|
||||||
The app will be available at `http://localhost:4173`.
|
# View logs
|
||||||
|
|
||||||
Persistent admin saves:
|
|
||||||
|
|
||||||
- Admin updates are written to `data/admin-content.json`.
|
|
||||||
- `docker-compose.yml` mounts `./data` into the container at `/app/data`.
|
|
||||||
- This keeps your edits after container restarts/rebuilds.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
- 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:
|
---
|
||||||
|
|
||||||
```bash
|
## Persistent Data
|
||||||
docker compose pull
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
All server-managed data lives under `/app/data` (or `./data` locally):
|
||||||
|
|
||||||
- The current project cards are sample placeholders.
|
| Path | Contents |
|
||||||
- Most metadata was scanned from live pages.
|
|---|---|
|
||||||
- Skywatch currently uses a manual fallback description due to a `401` response during automated scan.
|
| `admin-content.json` | Published CMS content |
|
||||||
|
| `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.
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* 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(/ /g, ' ').replace(/&/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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"notesByUser": {
|
||||||
|
"e43e7217-be69-49c6-a65d-edd0e7745884": {
|
||||||
|
"1-1-2": "Test"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"updatedAt": "2026-05-12T15:51:54.402Z"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 276 KiB |
@@ -1,12 +1,22 @@
|
|||||||
services:
|
services:
|
||||||
portfolio:
|
portfolio:
|
||||||
image: ghcr.io/nmemmert/siteforge:latest
|
image: git.necloud.us/nate/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`
|
||||||
|
- 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:
|
||||||
- ./data:/app/data
|
- /media/ZimaOS-HD/AppData/siteforge/data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -1,17 +1,213 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
|
||||||
|
|
||||||
DATA_FILE=/app/data/admin-content.json
|
DATA_DIR=${SITEFORGE_DATA_DIR:-/app/data}
|
||||||
SEED_FILE=/app/data-seed/admin-content.json
|
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 /app/data
|
mkdir -p "$DATA_DIR" || true
|
||||||
|
|
||||||
# Only seed from image defaults when no live data file exists yet.
|
# 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.
|
||||||
if [ ! -f "$DATA_FILE" ] && [ -f "$SEED_FILE" ]; then
|
if [ -d "$SEED_DIR" ]; then
|
||||||
echo "[siteforge] No admin-content.json found. Seeding from image defaults..."
|
for seed_file in "$SEED_DIR"/*.json; do
|
||||||
cp "$SEED_FILE" "$DATA_FILE"
|
[ -f "$seed_file" ] || continue
|
||||||
|
|
||||||
|
base_name=$(basename "$seed_file")
|
||||||
|
target_file="$DATA_DIR/$base_name"
|
||||||
|
|
||||||
|
if [ ! -f "$target_file" ]; then
|
||||||
|
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"
|
||||||
|
fi
|
||||||
|
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
|
||||||
@@ -19,5 +19,9 @@ 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',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -4,7 +4,24 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Siteforge</title>
|
<meta name="description" content="Verse by Verse with Nate — A verse-by-verse Scripture podcast exploring God's Word with depth, clarity, and practical application." />
|
||||||
|
<title>Verse by Verse with Nate</title>
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<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:image" content="https://versebyversewithnate.us/images/banner.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:alt" content="Verse by Verse with Nate podcast banner" />
|
||||||
|
<meta property="og:locale" content="en_US" />
|
||||||
|
<meta property="og:url" content="https://versebyversewithnate.us" />
|
||||||
|
<meta property="og:site_name" content="Verse by Verse with Nate" />
|
||||||
|
<link rel="canonical" href="https://versebyversewithnate.us" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<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" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,28 +1,52 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "2.24.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"api": "node server.js",
|
"api": "node --env-file=.env server.js",
|
||||||
"dev:full": "concurrently \"npm:api\" \"npm:dev\"",
|
"dev:full": "concurrently \"npm:api\" \"npm:dev\"",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"start": "node 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"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
"remark-gfm": "^4.0.1"
|
"react-simple-maps": "^3.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"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",
|
||||||
@@ -33,6 +57,7 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# 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)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 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.
|
||||||
|
After Width: | Height: | Size: 89 KiB |
@@ -1 +1,19 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<!-- Dark circle background -->
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0a0a0a"/>
|
||||||
|
<!-- Gold open book icon -->
|
||||||
|
<!-- Left page -->
|
||||||
|
<path d="M12 18 C12 17 13 16 14 16 C18 16 24 17.5 28 20 L28 47 C24 44.8 18 43.5 14 43.5 C13 43.5 12 42.6 12 41.5 Z" fill="#c8860a" opacity="0.9"/>
|
||||||
|
<!-- Right page -->
|
||||||
|
<path d="M52 18 C52 17 51 16 50 16 C46 16 40 17.5 36 20 L36 47 C40 44.8 46 43.5 50 43.5 C51 43.5 52 42.6 52 41.5 Z" fill="#c8860a" opacity="0.9"/>
|
||||||
|
<!-- Spine highlight -->
|
||||||
|
<rect x="30" y="16" width="4" height="31" rx="1" fill="#d4b87a"/>
|
||||||
|
<!-- Top ornament lines (left page) -->
|
||||||
|
<line x1="15" y1="23" x2="26" y2="24.5" stroke="#0a0a0a" stroke-width="1.2" stroke-linecap="round" opacity="0.6"/>
|
||||||
|
<line x1="15" y1="27" x2="26" y2="28.3" stroke="#0a0a0a" stroke-width="1.2" stroke-linecap="round" opacity="0.6"/>
|
||||||
|
<line x1="15" y1="31" x2="26" y2="32" stroke="#0a0a0a" stroke-width="1.2" stroke-linecap="round" opacity="0.6"/>
|
||||||
|
<!-- Top ornament lines (right page) -->
|
||||||
|
<line x1="49" y1="23" x2="38" y2="24.5" stroke="#0a0a0a" stroke-width="1.2" stroke-linecap="round" opacity="0.6"/>
|
||||||
|
<line x1="49" y1="27" x2="38" y2="28.3" stroke="#0a0a0a" stroke-width="1.2" stroke-linecap="round" opacity="0.6"/>
|
||||||
|
<line x1="49" y1="31" x2="38" y2="32" stroke="#0a0a0a" stroke-width="1.2" stroke-linecap="round" opacity="0.6"/>
|
||||||
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Artwork" xmlns="http://www.w3.org/2000/svg" width="113.7899" height="26.164" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 113.7899 26.164">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<image width="2048" height="2048" transform="translate(.5 .5) scale(.0123)" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAACAAAAAgACAMAAACFeSMCAAABJlBMVEVHcEy6f+HUq+/ZsvLSsOfgtvnpwv7srv/fp/zSpu7XnPjJnObMkO/BgubBiOG8euKiWdP1t/+2a971z/+xdNrBk+KyZNynbNKkY9GsXtirc8+lWtGlVdClVNClbMuiUs6ocM7/3v+gUMy8hNy5gdm3j9K6k9S1jNC0etudTsqyic+UU8avhsyze9S2ftaudtGaTMjXwueweNKsg8ji0e+VW8Cof8SWSsWYXsL06vrNrOKTR8P78f7OtuHTvOWbYsWke8HKqN/HpN2PRMCeZcbEoNrAnNj9+f7////u5Pbp3fG9mNaLQb3byemhacmHPruJR76NUrqFPLmCOreBObaSWL6AOLV9OLR+N7R8NrOARqx7NLKGTLJ5M7B3Mq92Ma5xLKloI6CXA+vBAAAAEXRSTlMAKVue////////////////fYl6K8AAB4R5SURBVHgB7JcHkuswDEN305uj5Of+d/2E3xAzHkXe3oVGijkAnIeOji/DY2AxYglWy7Ww+SisSUKSpyLmiWziXbH1JNCOjMAzzEEKTI8a+0nYCDV4kA67fQTUExKW/WIcGSeJ0aZlvy8GKcc5oslhOCuNZVkGLovLIvA44uG3o6Ojo7c8Ff/+/b62rQkzvOZBrEWwXLXLWg1+sb0g3C51VsvVrm0XqhlayX5u/TR5R0g5IoN7jTU33kx2jEOEyUhGze8o97Sk4EWyIVvDL7ajtqPU5CmUIWqGPDgmdZJkb8CHV+AcHkzImOcgnzKgUMrQRFn+y8+Dn/Nx0NHR0ZE9L9xp+ZsU8QLM/TVHXNtUUONX2e3PVT8QdXl7qSr+WXC1m36qrtEzuKpeMmOsbQRirhlc93nNMgd77LN0l+Mfe3c9Q1dzp9RPdL5dFz5Vn0PUzmuC04THMKLgoTkeT7Kgw1EJfHwRhoqEZ4sSAfx8Kcq9U9GnweV7fBh0dHT0qq+b/iaLN1/QS1tfbNQ5XQ5ZpNjChIxybCTgHzg3cJXmqt0b6U1K5rVR9jR6SP/neVRWRoiaFLc2BtbYR/J7ME+ymBIlrznq1j9IWkw/CCQTTOyk8WUlC+JGtWseT7aupwhoZfsLdcWj12KQkj7iquarAfzIBGzvgRIcSokwSmBZLuOHwQd+FnR0dPSyd9ffkgG98qLl5U1vIpg9737/z97Z6LaNJGHwgHXOULSmg9zm/V/1pK8Ghc6kY+pkytnNTXdP/5FeLH5LPRwywn3ekXefXn/nA/f+iryvK8GR3gfyV1cUR2j4nvAcVsN6tuldp4zzp+S2HewFPTg3ogRrExtjlieC82Hi3gpDSHESH9RraiX/uFR2683mqT4XvYcbFHp0zN5J/RQ4mliv4t1A/1AJ6xNQelL/JXZxeCIL+ePrly9f1m+CO2XJksX7hvbu4t/1mL5j/WQiXpBTGorY3hdIXWt7zurmIj0roRvgG6g/W7guOgKNMrY7yRP0rjROwP87tFeOq5pFpT2D/TzGq7YkO45AeaZ2qJfyBoyLzPWF9zL/EsxoxrMkPKnTPe7dslXsQ33LXjVE/Ns8kPyvrRJGdRXKEUmIrtQvyRT2CdYvgiVLlkzIn4EfcbK/V4S9HpX89eG7LDc1uZXwmtEWEbLTd1ce69D+fFld38VVa/zz6RKlPTv4yMz3UyLDPo1JCSfThu2fLfM8HotOLknbcZmaQXQNJcd5y9i4H8gP44W8jHeJ+liVWh9G/M1z+sVQbNZhFfX2j5zwVchts9NISYx3yh9f/u9/DyxZspgf0v+HgCqm+9K+IhfVQXMuke4z3dROVXEuxgm17M/RG5/pmqn2f1D36t2fD8JlPB2yE3r9uxPNhvAifNaaUjjFewhPoDeqCXaLiJWTvWhXi2Wul/+yHs5X1rNxzzV38aeN+/SR80Hk3zSQnwXrKXZUvrvNjz9CXrPU3UHfpdC3e6zkeOH6MfDby5JF/U8O+Y75kv4e4GuO+dYd6ee6Ml6uU/azuWSOkcNvqtFyAfNcPVkb5fXwn1zpe83o3nzcJY+j96Y2x+0shHsCpjKQ+0hei9ptHtZrZ0/jab1ylQDT46MA3WneiC/BOV+oc8H8KNlUec/Ev0/8AnnFOf8Q+WG4t6xpC3R6XvwQ+VrStTPw28iSJcz6GfUr9N+B/MhIh7N7y/69tJfzzPDPT6Ic3Ap7WR7Anz5lZa4/2R6xvg5PQ2aLewd26X2S6S6o/UR8wnuEftLYjp5CeBnfazl2p9rLsoWQAXjZfmrHeCPpyGU9ZX0w766+Da5RSH/ySIJydr1LNvykYX2iStdy1kr34zf3XzGRXwoTLEvS6z9cvrpsjNzWX2tf4J8rSxb3/12xX8Vqn/eV+Mntyfld2j+rZZv+qUzsob6PzoP4AXsuO57Xydzz8anoSW5TboPudOjtQfsGsqsD7bn9sxadKG6mYnOtUBSci/Kq/fV54Af1rLB84Jz0kqUQ8xfhcpyMNyo2DpnqcSqUTyAR9p2aCHiBb7D7fuIPZ2IB1rVZbH0A4fGz2eauGk1opV6/BP4ZsmRt89dxv5Le4qYhn5iUomP83HCoryfpr+HJR+8jOss/O6V7OB5yO5ufrE+qGqyTTeTu1Ht/do/m6Xmn96BcwuOqahZkhiwty96Zos7pagP09roKw53b8ajH8y4h3jvIc0dy20Wszw/Yw1djqsDvaW82Ed/M9CDqq6XAOd5/HOLluxVQR0vRGtFk+pNSXTO6/BL4199LlixZAvjnXf79r/AIejlP1kDecv7wTaF9PVznFr7P3p/dp6cdgOMd2ikmxTotbqa35mg+7nM7fp7V1c/2LFuHoZohmdhmY75Xkzfv4fKfw7kpD8MTfIs+0Qfy0VEUtKcX4Qbzw0b7iebq3HC2R0lVm9e1XdOG8XrCoQf1cURylkFv9sChXk2lr1AfLY2g3arYKx5NkR8Cf48dgSVL1sjvxD9/effGUT/mtN+/eP9cHKvC/ruD9LRh+zUhJ/HJ+8ln7QP4mK5EXUoX9WcG9c94TY0acB3CVa+UpZ39W7iu3aifq3deV8824vtX6AbspfuINJIX1iMmR/O9ypY1q4l605f0t++G+s18DPME08ef1NcexXgBP8/nqn2F1mTqG1i/X/2Pv6b6ujYEliz5JSL5nfgvVbQnf0H+MPI3xPm+An/APbz3HD7H8iT9iQjo4+MmopPoRPxPt9pxWJxdVbMxPEEDwRPKCXepoVW5Ph6y+7Vbye4bcymE+bgeR0GPjqLYm9hu68jjeCqGU998VL/lQqb5+IQ4uW7YiMrRr+FrOMLjKF9RjVML072D1PUdh3vO74HeRJ3TqVW8F6wjH/ZgYMmSJWW33zkf8iuK4MfB/h3is6XPyXzGeol/0aBe8AP54eqj+sJ4Aa9i/axeWa1a484jBdrlozXi/L1q0rLczI7BY/PTgt2euktFoS8tYZ4MR1BEutffK9/Mtp+pjO+1Un7bLuvl4kW9PtGJHm+yHbyV/9pM9ybN+fxj8S+3C3/tto/vvdeEeLv2bTGOI7ccXifbyawJNv3fHL8D1s+AR8qSdbTfoR/ou3427hdnVDyjL/J9sc6tfUtfhhfxhkZlPtmPu/NY1dE6n+7itMkxWr9tM8P8oqZXcxyX8z+wn/XZ2yrge/HiEVRXtmq9YrPae5lUmm+h98WiQt06jceLrJ/P582zvYli514Rh2qarakxVqtc3FeBTm5RpM74mo6QzNLKmkDJ8uL6GfAwWbLYX6f+duIX/c3QX+W5kr98XCfEP10dpUfyn/DEZrAnkCazaGCPK5+naaB+u55N52FcfmvdXD7QLLMjNstM7tKD9OtKTm+Q3XSa0/X381xX4paFvaEmjXoUL467Q2/9BsGZ6odea3zU3wHkVaxNj5DXqrIek/dmR3FemVBuU+63iv1cO7KrmOTt8Dxy7yzay6vBRBH0TdeUijy6fgY8RpYs9Dfwl/wYqxGZH/VEPi/kkTw54XdH8ksC5PV+g/77t9wo+5fb9rWiO0o2AkkBOCk9m8VUa5+wS+foPq/vfcj+rculOnmMQNzeg3W1a7Cc4jdi+Dw6F0fOAL/hcg3bPmSi/4Hy+heLh6Femcd7Slft3rVnb9RsKvUSSjrDuJfXzrxk5Z0d9Q3eJPqtzNa7AkuWvJ/9nu3DevQbeu4/M+z7sZ2UfiXvx9ft3cfHyXp43w300bPMP+0T/k8pL9UxxHIckpPacnu87OZdLDvaPqVtG01aftuqXZszyRXG9G/XcAO0bwc75kKC89hAdvQacXDd5/PTE3qukxoeIQJexUn3eGXCvuEYKTQ3qDTuR7yGGPFGvIuAilsT7yex7/pF8hq771fAkiWL/dM5P7mvdMf6q4j9p4sN6Dv5Q3q39yvzDX6afn67XfqDevykPe8dwg1BufjWUOV8+KF2Yb1/S/RqBofzqwD0HdVKQ23bLyn9O+QFrJNc3YD22HZPSLRB4mV3769G8iHyOqyq2KYqZfPI3lI5FPcq5sBv7IDeK9YMyl6T5kZv1ZsZ5LqZ4tW/raz3BJYs+R/3/Nnsxxry74L/k9j3H6M/Oe+j4h43kI9KfeuM+fukZ7CfHpnXTfeEtHTGm5BtMCoCeu5icBuqbxQk2+2qM9lR3SC5SRXpHk2RiPmlWzuk5InxZr1sD57j8TPgxTddxY6VzD9OCs1ntb/HeBNLk373vN6GmCk2LHGWv52scwE7smS94Qf73xz64xSx78DvWf4UyfzkThSL9G/au42/M9qDezFfTtNN9D/fN8A7fG+C2pH7RlQ3lWFO9nUbHmdiUKQ4QeUzd0kucQQkl1OLdYNCYcfc5EGz+5vzO0LTNfcUq8OlJbnH8rTbtSbifh7pWd4Line truncated
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M49.6308,11.8432c2.4126,0,4.0928,1.6631,4.0928,4.0843,0,2.4299-1.7146,4.1016-4.1532,4.1016h-2.6713v4.2481h-1.9302v-12.434h4.6617ZM46.8992,18.4092h2.2145c1.6803,0,2.6367-.9047,2.6367-2.473s-.9563-2.4643-2.6281-2.4643h-2.2232v4.9373Z"/>
|
||||||
|
<path class="st0" d="M54.0083,19.7448c0-2.9124,1.7147-4.6962,4.2998-4.6962s4.2998,1.7837,4.2998,4.6962c0,2.9211-1.7061,4.7048-4.2998,4.7048s-4.2998-1.7837-4.2998-4.7048ZM60.7121,19.7448c0-2.0076-.9479-3.1622-2.404-3.1622s-2.4042,1.1546-2.4042,3.1622c0,2.0164.9479,3.171,2.4042,3.171,1.4648,0,2.404-1.1546,2.404-3.171Z"/>
|
||||||
|
<path class="st0" d="M63.643,19.7448c0-2.8607,1.5165-4.6789,3.7913-4.6789,1.3011,0,2.3352.6549,2.8435,1.6544h.0346v-4.8772h1.8698v12.434h-1.8095v-1.5424h-.0346c-.5342,1.034-1.5682,1.6975-2.8865,1.6975-2.2921,0-3.8086-1.8268-3.8086-4.6875ZM65.5472,19.7448c0,1.9129.9219,3.1107,2.3955,3.1107,1.4304,0,2.3867-1.215,2.3867-3.1107,0-1.8785-.9563-3.1105-2.3867-3.1105-1.4736,0-2.3955,1.2063-2.3955,3.1105Z"/>
|
||||||
|
<path class="st0" d="M79.8816,18.3317c-.1895-.9652-.9133-1.732-2.1283-1.732-1.439,0-2.3869,1.1977-2.3869,3.1451,0,1.9905.9565,3.1451,2.4042,3.1451,1.146,0,1.8956-.6289,2.111-1.6802h1.7924c-.1983,1.9302-1.7407,3.2399-3.9206,3.2399-2.5936,0-4.2825-1.7751-4.2825-4.7048,0-2.878,1.6888-4.6962,4.2652-4.6962,2.3352,0,3.7569,1.4993,3.9206,3.2831h-1.7751Z"/>
|
||||||
|
<path class="st0" d="M82.6315,21.7009c0-1.5856,1.215-2.5592,3.3692-2.6799l2.4816-.1464v-.6979c0-1.0082-.6808-1.6114-1.8181-1.6114-1.0772,0-1.7493.5169-1.913,1.327h-1.7578c.1034-1.6373,1.4993-2.8436,3.7396-2.8436,2.1974,0,3.6019,1.1633,3.6019,2.9814v6.2472h-1.7837v-1.4907h-.043c-.5255,1.0082-1.6715,1.6458-2.8607,1.6458-1.7751,0-3.0158-1.1029-3.0158-2.7314ZM88.4823,20.8823v-.7152l-2.2318.1378c-1.1116.0776-1.7405.5688-1.7405,1.3443,0,.7926.6549,1.3097,1.6544,1.3097,1.3011,0,2.3179-.8962,2.3179-2.0766Z"/>
|
||||||
|
<path class="st0" d="M95.3657,15.0486c2.1111,0,3.4812,1.1719,3.5415,2.8436h-1.7493c-.1034-.8789-.7496-1.4304-1.7922-1.4304-1.0082,0-1.6803.4911-1.6803,1.215,0,.5686.4137.9563,1.396,1.1804l1.4734.3361c1.8527.4308,2.5592,1.1546,2.5592,2.4643,0,1.6544-1.5424,2.7919-3.6793,2.7919-2.2662,0-3.6278-1.1116-3.8001-2.835h1.8441c.1724.9479.8186,1.4133,1.9561,1.4133,1.1201,0,1.8095-.4654,1.8095-1.2063,0-.586-.3361-.9308-1.327-1.1548l-1.4734-.3445c-1.7234-.3964-2.5765-1.2236-2.5765-2.542,0-1.62,1.4133-2.7316,3.4985-2.7316Z"/>
|
||||||
|
<path class="st0" d="M103.0107,13.0668v2.1456h1.7232v1.4736h-1.7232v4.9977c0,.7842.3445,1.1373,1.1029,1.1373.1895,0,.4911-.0171.6116-.043v1.4734c-.2068.0519-.6203.0863-1.034.0863-1.8354,0-2.5506-.6979-2.5506-2.4472v-5.2044h-1.3183v-1.4736h1.3183v-2.1456h1.87Z"/>
|
||||||
|
<path class="st0" d="M109.5416,15.0486c2.1111,0,3.4812,1.1719,3.5415,2.8436h-1.7493c-.1034-.8789-.7496-1.4304-1.7922-1.4304-1.0082,0-1.6803.4911-1.6803,1.215,0,.5686.4137.9563,1.396,1.1804l1.4734.3361c1.8527.4308,2.5592,1.1546,2.5592,2.4643,0,1.6544-1.5424,2.7919-3.6793,2.7919-2.2662,0-3.6278-1.1116-3.8001-2.835h1.8441c.1724.9479.8186,1.4133,1.9561,1.4133,1.1201,0,1.8095-.4654,1.8095-1.2063,0-.586-.3361-.9308-1.327-1.1548l-1.4734-.3445c-1.7234-.3964-2.5765-1.2236-2.5765-2.542,0-1.62,1.4133-2.7316,3.4985-2.7316Z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M39.741,14.162c-1.1094-.0821-2.0505.6196-2.5756.6196-.5327,0-1.3541-.602-2.2251-.5855-1.1436.0168-2.1976.6654-2.7873,1.6894-1.1878,2.0617-.3032,5.1168.8538,6.7898.5659.8175,1.2409,1.7387,2.1275,1.7052.8534-.0336,1.1762-.552,2.2078-.552,1.0309,0,1.3217.552,2.2242.5354.9185-.0171,1.5005-.8344,2.0623-1.6564.6501-.9489.9172-1.8676.9331-1.9162-.0203-.0077-1.7905-.6869-1.8082-2.7263-.016-1.7051,1.3918-2.5247,1.4558-2.5641-.7991-1.1769-2.0334-1.3067-2.4682-1.3389Z"/>
|
||||||
|
<path class="st0" d="M39.0565,13.0576c.4712-.5694.7882-1.3624.7009-2.1512-.6781.0269-1.4983.4517-1.9846,1.0208-.4356.5041-.8181,1.3115-.7142,2.0841.7558.0584,1.5272-.3839,1.998-.9537Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M32.3404,1.8525h.9297v5.1875h2.8057v.8018h-3.7354V1.8525Z"/>
|
||||||
|
<path class="st0" d="M37.1695,1.9687c0-.3115.2534-.5645.5645-.5645.3076,0,.5645.2529.5645.5645,0,.3066-.2568.5605-.5645.5605-.311,0-.5645-.2539-.5645-.5605ZM37.2818,3.3339h.8965v4.5078h-.8965V3.3339Z"/>
|
||||||
|
<path class="st0" d="M39.3887,6.663v-.0049h.9009l.0039.0049c.1123.3398.4399.5684.9756.5684.5557,0,.9419-.2529.9419-.623v-.0078c0-.2822-.2114-.4736-.7349-.5938l-.7178-.166c-.8506-.1953-1.2451-.5811-1.2451-1.2412v-.0039c0-.7842.7305-1.3486,1.7515-1.3486,1.0293,0,1.689.5146,1.7681,1.249v.0039h-.8594v-.0078c-.0869-.3154-.4062-.5518-.9131-.5518-.4937,0-.8506.2441-.8506.6055v.0088c0,.2822.2075.4561.7139.5762l.7139.1621c.8672.2031,1.2822.5732,1.2822,1.2246v.0078c0,.835-.7969,1.4033-1.8672,1.4033-1.0879,0-1.7764-.5107-1.8638-1.2656Z"/>
|
||||||
|
<path class="st0" d="M44.5836,6.6543v-2.6104h-.7051v-.71h.7051v-1.1367h.9175v1.1367h.9673v.71h-.9673v2.3945c0,.4854.1826.6846.6265.6846.1372,0,.2158-.0039.3408-.0166v.7188c-.1455.0244-.3115.0449-.4858.0449-.9917,0-1.3989-.3486-1.3989-1.2158Z"/>
|
||||||
|
<path class="st0" d="M47.3658,5.6005v-.0049c0-1.4062.8135-2.3486,2.0713-2.3486s2.0293.9092,2.0293,2.2617v.3154h-3.1958c.0166.8633.4897,1.3652,1.2329,1.3652.5767,0,.9209-.2861,1.0293-.5186l.0166-.0332.8672-.0039-.0083.0371c-.1494.5938-.772,1.2578-1.9258,1.2578-1.3198,0-2.1167-.9004-2.1167-2.3281ZM48.2828,5.1806h2.291c-.0703-.8047-.5225-1.1953-1.1328-1.1953-.6143,0-1.0791.4199-1.1582,1.1953Z"/>
|
||||||
|
<path class="st0" d="M52.6553,3.3339h.9009v.6846h.0664c.2197-.4814.6514-.7715,1.3071-.7715,1.0083,0,1.5649.6016,1.5649,1.6768v2.918h-.8965v-2.7061c0-.7471-.3115-1.1172-.9717-1.1172-.6597,0-1.0703.4531-1.0703,1.1787v2.6445h-.9009V3.3339Z"/>
|
||||||
|
<path class="st0" d="M60.0855,5.5918v-.0078c0-1.4492.8135-2.3369,2.1333-2.3369,1.3159,0,2.1294.8838,2.1294,2.3369v.0078c0,1.4531-.8096,2.3369-2.1294,2.3369-1.3237,0-2.1333-.8838-2.1333-2.3369ZM63.4307,5.5918v-.0078c0-1.001-.4399-1.5859-1.2119-1.5859-.7759,0-1.2119.585-1.2119,1.5859v.0078c0,1,.436,1.5859,1.2119,1.5859.772,0,1.2119-.5898,1.2119-1.5859Z"/>
|
||||||
|
<path class="st0" d="M65.5367,3.3339h.9004v.6846h.0664c.2202-.4814.6519-.7715,1.3076-.7715,1.0088,0,1.5645.6016,1.5645,1.6768v2.918h-.8965v-2.7061c0-.7471-.311-1.1172-.9707-1.1172-.6602,0-1.0713.4531-1.0713,1.1787v2.6445h-.9004V3.3339Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 649 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 891 B |
|
After Width: | Height: | Size: 127 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 972 B |
@@ -0,0 +1,11 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 904 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 1019 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 59 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 732 B |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Disallow: /admin
|
||||||
|
Disallow: /api/
|
||||||
|
Disallow: /thanks
|
||||||
|
Disallow: /subscribe/thanks
|
||||||
|
|
||||||
|
Sitemap: https://versebyversewithnate.us/sitemap.xml
|
||||||
@@ -0,0 +1,899 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Gospel Harmony — The Trial & Crucifixion</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700;900&family=EB+Garamond:ital,wght@0,400;0,500;0,600;1,400;1,500&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--parchment: #f5edd8;
|
||||||
|
--parchment-dark: #e8d9b8;
|
||||||
|
--ink: #1a1208;
|
||||||
|
--ink-light: #3d2c10;
|
||||||
|
--crimson: #8b1a1a;
|
||||||
|
--gold: #b8860b;
|
||||||
|
--gold-light: #d4a843;
|
||||||
|
--matt-color: #4a6fa5;
|
||||||
|
--mark-color: #7a4a8f;
|
||||||
|
--luke-color: #2e7d52;
|
||||||
|
--john-color: #b5651d;
|
||||||
|
--shadow: rgba(26,18,8,0.18);
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
background: #1a0f05;
|
||||||
|
font-family: 'EB Garamond', Georgia, serif;
|
||||||
|
color: var(--ink);
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='400' height='400' filter='url(%23n)' opacity='0.07'/%3E%3C/svg%3E");
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.scroll-wrapper {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1.5rem 4rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3.5rem 2rem 2rem;
|
||||||
|
}
|
||||||
|
.header-rule {
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--gold), var(--crimson), var(--gold), transparent);
|
||||||
|
margin-bottom: 1.8rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.header-rule::before, .header-rule::after {
|
||||||
|
content: '✦';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: #1a0f05;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
.header-rule::before { left: 20%; }
|
||||||
|
.header-rule::after { right: 20%; }
|
||||||
|
header h1 {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: clamp(1.6rem, 5vw, 3rem);
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--parchment);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-shadow: 0 2px 8px rgba(0,0,0,0.5);
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
header h1 span { color: var(--gold-light); }
|
||||||
|
header .subtitle {
|
||||||
|
font-family: 'EB Garamond', serif;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--parchment-dark);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.6rem 1.4rem;
|
||||||
|
margin: 2rem auto 1rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: rgba(245,237,216,0.06);
|
||||||
|
border: 1px solid rgba(184,134,11,0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
color: var(--parchment);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.legend-item.dimmed { opacity: 0.35; }
|
||||||
|
.legend-dot {
|
||||||
|
width: 14px; height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(255,255,255,0.3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 1rem 0 2.5rem;
|
||||||
|
}
|
||||||
|
.filter-btn {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
padding: 0.35rem 0.9rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1px solid;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.filter-btn.active-filter { color: var(--ink); font-weight: 700; }
|
||||||
|
.filter-btn:not(.active-filter) {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--parchment-dark);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.filter-btn:hover { opacity: 1; }
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 2.5rem;
|
||||||
|
}
|
||||||
|
.timeline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0.95rem;
|
||||||
|
top: 0; bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: linear-gradient(180deg, var(--gold) 0%, var(--crimson) 50%, var(--gold) 100%);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.phase-header {
|
||||||
|
position: relative;
|
||||||
|
margin: 2.5rem 0 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.phase-header::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -2.5rem;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--gold);
|
||||||
|
}
|
||||||
|
.phase-node {
|
||||||
|
width: 1.8rem; height: 1.8rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--gold), var(--crimson));
|
||||||
|
border: 3px solid var(--parchment-dark);
|
||||||
|
position: absolute;
|
||||||
|
left: -2.42rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
box-shadow: 0 0 12px rgba(184,134,11,0.5);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.phase-label {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--gold-light);
|
||||||
|
padding: 0.2rem 0.8rem;
|
||||||
|
border: 1px solid rgba(184,134,11,0.4);
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(26,9,5,0.6);
|
||||||
|
}
|
||||||
|
.phase-title {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--parchment);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.event-card {
|
||||||
|
position: relative;
|
||||||
|
margin: 0.65rem 0;
|
||||||
|
background: linear-gradient(135deg, rgba(245,237,216,0.96) 0%, rgba(232,217,184,0.94) 100%);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1rem 1.2rem 1rem 1.4rem;
|
||||||
|
border-left: 4px solid var(--crimson);
|
||||||
|
box-shadow: 2px 3px 14px var(--shadow), inset 0 1px 0 rgba(255,255,255,0.4);
|
||||||
|
transition: transform 0.18s, box-shadow 0.18s, opacity 0.25s;
|
||||||
|
}
|
||||||
|
.event-card:hover {
|
||||||
|
transform: translateX(3px);
|
||||||
|
box-shadow: 4px 5px 20px rgba(26,18,8,0.28);
|
||||||
|
}
|
||||||
|
.event-card.hidden { display: none; }
|
||||||
|
.event-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -2.09rem;
|
||||||
|
top: 1.1rem;
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--parchment);
|
||||||
|
border: 2px solid var(--crimson);
|
||||||
|
box-shadow: 0 0 0 2px rgba(26,18,8,0.15);
|
||||||
|
}
|
||||||
|
.event-title {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.event-body {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ink-light);
|
||||||
|
line-height: 1.6;
|
||||||
|
font-style: italic;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.gospel-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
/* shared badge base */
|
||||||
|
.badge {
|
||||||
|
font-family: 'Cinzel', serif;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1px solid;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
a.badge {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.15s, transform 0.15s;
|
||||||
|
}
|
||||||
|
a.badge:hover {
|
||||||
|
filter: brightness(1.25);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.badge-matt { background: var(--matt-color); border-color: #3a5a8a; }
|
||||||
|
.badge-mark { background: var(--mark-color); border-color: #6a3a7f; }
|
||||||
|
.badge-luke { background: var(--luke-color); border-color: #1e6040; }
|
||||||
|
.badge-john { background: var(--john-color); border-color: #8b4513; }
|
||||||
|
.ref-text {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #7a5a2a;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-style: normal;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.unique-note {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--crimson);
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
border-top: 1px dashed rgba(139,26,26,0.25);
|
||||||
|
}
|
||||||
|
.bsb-hint {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: #9a7a3a;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem 1.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(245,237,216,0.35);
|
||||||
|
font-style: italic;
|
||||||
|
font-family: 'EB Garamond', serif;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.timeline { padding-left: 2rem; }
|
||||||
|
.timeline::before { left: 0.7rem; }
|
||||||
|
.event-card::before { left: -1.6rem; }
|
||||||
|
.phase-node { left: -1.92rem; }
|
||||||
|
.phase-header::before { left: -2rem; width: 1.6rem; }
|
||||||
|
.event-card { padding: 0.9rem 1rem 0.9rem 1.1rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="scroll-wrapper">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="header-rule"></div>
|
||||||
|
<h1>A <span>Gospel Harmony</span><br>The Trial & Crucifixion of Jesus</h1>
|
||||||
|
<p class="subtitle">Matthew · Mark · Luke · John — Woven into One Timeline</p>
|
||||||
|
<div class="header-rule" style="margin-top:1.6rem;margin-bottom:0"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="legend">
|
||||||
|
<div class="legend-item" data-gospel="matt" onclick="toggleGospel('matt',this)">
|
||||||
|
<div class="legend-dot" style="background:var(--matt-color)"></div> Matthew
|
||||||
|
</div>
|
||||||
|
<div class="legend-item" data-gospel="mark" onclick="toggleGospel('mark',this)">
|
||||||
|
<div class="legend-dot" style="background:var(--mark-color)"></div> Mark
|
||||||
|
</div>
|
||||||
|
<div class="legend-item" data-gospel="luke" onclick="toggleGospel('luke',this)">
|
||||||
|
<div class="legend-dot" style="background:var(--luke-color)"></div> Luke
|
||||||
|
</div>
|
||||||
|
<div class="legend-item" data-gospel="john" onclick="toggleGospel('john',this)">
|
||||||
|
<div class="legend-dot" style="background:var(--john-color)"></div> John
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<button class="filter-btn active-filter" style="border-color:var(--gold);background:var(--gold);color:var(--ink)" onclick="filterPhase('all',this)">All Events</button>
|
||||||
|
<button class="filter-btn" style="border-color:#555" onclick="filterPhase('arrest',this)">Arrest</button>
|
||||||
|
<button class="filter-btn" style="border-color:#555" onclick="filterPhase('jewish-trial',this)">Jewish Trial</button>
|
||||||
|
<button class="filter-btn" style="border-color:#555" onclick="filterPhase('roman-trial',this)">Roman Trial</button>
|
||||||
|
<button class="filter-btn" style="border-color:#555" onclick="filterPhase('crucifixion',this)">Crucifixion</button>
|
||||||
|
<button class="filter-btn" style="border-color:#555" onclick="filterPhase('burial',this)">Burial</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeline" id="timeline"></div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
A harmonic arrangement of Matthew 26–27, Mark 14–15, Luke 22–23, and John 18–19.<br>
|
||||||
|
Click any Gospel badge to open that passage in the Berean Standard Bible (BSB) at BibleHub.
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// BSB URL helper — BibleHub format: https://biblehub.com/bsb/matthew/26.htm
|
||||||
|
const BSB = {
|
||||||
|
matt: (ch) => `https://biblehub.com/bsb/matthew/${ch}.htm`,
|
||||||
|
mark: (ch) => `https://biblehub.com/bsb/mark/${ch}.htm`,
|
||||||
|
luke: (ch) => `https://biblehub.com/bsb/luke/${ch}.htm`,
|
||||||
|
john: (ch) => `https://biblehub.com/bsb/john/${ch}.htm`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const GOSPELS = ['matt','mark','luke','john'];
|
||||||
|
const activeGospels = new Set(GOSPELS);
|
||||||
|
let activePhase = 'all';
|
||||||
|
|
||||||
|
const data = [
|
||||||
|
// ── PHASE 1: ARREST ──────────────────────────────────────────
|
||||||
|
{ phase:'arrest', phaseLabel:'Phase I', phaseTitle:'The Arrest in Gethsemane', events:[
|
||||||
|
{
|
||||||
|
title:'The Last Supper & Passover Meal',
|
||||||
|
body:"Jesus eats the Passover meal with his twelve disciples, institutes the Lord's Supper, and predicts his betrayal.",
|
||||||
|
gospels:['matt','mark','luke','john'],
|
||||||
|
refs:'Matt 26:20–29 · Mark 14:17–25 · Luke 22:14–23 · John 13:1–30',
|
||||||
|
unique:"John alone records Jesus washing the disciples' feet and the extended farewell discourse (John 13–17).",
|
||||||
|
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(13) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:"Jesus Predicts Peter's Denial",
|
||||||
|
body:"Jesus tells Peter that before the rooster crows, he will deny him three times.",
|
||||||
|
gospels:['matt','mark','luke','john'],
|
||||||
|
refs:'Matt 26:31–35 · Mark 14:27–31 · Luke 22:31–34 · John 13:36–38',
|
||||||
|
unique:'Luke adds that Jesus prayed specifically for Peter that his faith would not fail.',
|
||||||
|
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(13) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'The Agony in Gethsemane',
|
||||||
|
body:'Jesus prays alone in the garden in great anguish: "Father, if you are willing, remove this cup from me; nevertheless, not my will, but yours, be done."',
|
||||||
|
gospels:['matt','mark','luke'],
|
||||||
|
refs:'Matt 26:36–46 · Mark 14:32–42 · Luke 22:39–46',
|
||||||
|
unique:'Luke alone records that an angel appeared to strengthen Jesus, and that his sweat became like drops of blood.',
|
||||||
|
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'Judas Arrives with the Crowd',
|
||||||
|
body:'Judas leads a crowd with swords and clubs, sent from the chief priests and elders, into the garden.',
|
||||||
|
gospels:['matt','mark','luke','john'],
|
||||||
|
refs:'Matt 26:47 · Mark 14:43 · Luke 22:47 · John 18:3',
|
||||||
|
unique:'John specifies it was a Roman cohort plus Jewish officers, and that they carried lanterns and torches.',
|
||||||
|
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(18) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:"The Betrayer's Kiss",
|
||||||
|
body:"Judas greets Jesus with a kiss as the prearranged sign of identification.",
|
||||||
|
gospels:['matt','mark','luke'],
|
||||||
|
refs:'Matt 26:48–49 · Mark 14:44–45 · Luke 22:47–48',
|
||||||
|
unique:'John omits the kiss; instead Jesus steps forward and declares "I am he," causing the crowd to fall to the ground.',
|
||||||
|
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'Jesus Identifies Himself — "I Am He" (John)',
|
||||||
|
body:'Jesus asks "Whom do you seek?" When they say "Jesus of Nazareth," he says "I am he," and the entire crowd falls backward to the ground.',
|
||||||
|
gospels:['john'],
|
||||||
|
refs:'John 18:4–9',
|
||||||
|
unique:'Unique to John — a powerful "I Am" moment that echoes the divine name.',
|
||||||
|
bsbLinks:{ john:BSB.john(18) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:"The Servant's Ear Cut Off",
|
||||||
|
body:"One of those with Jesus draws a sword and cuts off the right ear of the high priest's servant, Malchus.",
|
||||||
|
gospels:['matt','mark','luke','john'],
|
||||||
|
refs:'Matt 26:51 · Mark 14:47 · Luke 22:50 · John 18:10',
|
||||||
|
unique:'Only John names the attacker as Peter and the servant as Malchus. Only Luke records that Jesus healed the ear.',
|
||||||
|
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(18) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'Jesus Rebukes the Sword & Surrenders',
|
||||||
|
body:'"Put your sword back in its place. Shall I not drink the cup the Father has given me?" Then all the disciples fled.',
|
||||||
|
gospels:['matt','mark','luke','john'],
|
||||||
|
refs:'Matt 26:52–56 · Mark 14:48–50 · Luke 22:51–53 · John 18:11',
|
||||||
|
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(18) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'The Young Man Who Fled (Mark)',
|
||||||
|
body:'A young man follows Jesus wearing only a linen cloth; when seized, he leaves the cloth behind and flees naked.',
|
||||||
|
gospels:['mark'],
|
||||||
|
refs:'Mark 14:51–52',
|
||||||
|
unique:'Unique to Mark — possibly a self-reference by the author.',
|
||||||
|
bsbLinks:{ mark:BSB.mark(14) }
|
||||||
|
},
|
||||||
|
]},
|
||||||
|
|
||||||
|
// ── PHASE 2: JEWISH TRIAL ─────────────────────────────────────
|
||||||
|
{ phase:'jewish-trial', phaseLabel:'Phase II', phaseTitle:'The Jewish Trial', events:[
|
||||||
|
{
|
||||||
|
title:'Jesus Led to Annas (John)',
|
||||||
|
body:"Jesus is first brought to Annas, father-in-law of Caiaphas, who questions Jesus about his disciples and teaching.",
|
||||||
|
gospels:['john'],
|
||||||
|
refs:'John 18:12–14, 19–24',
|
||||||
|
unique:'Unique to John. After the interrogation, Annas sends Jesus bound to Caiaphas.',
|
||||||
|
bsbLinks:{ john:BSB.john(18) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:"Peter Follows to the Courtyard",
|
||||||
|
body:"Peter and another disciple follow Jesus to the high priest's courtyard. Peter stands warming himself by the fire.",
|
||||||
|
gospels:['matt','mark','luke','john'],
|
||||||
|
refs:'Matt 26:58 · Mark 14:54 · Luke 22:54–55 · John 18:15–16',
|
||||||
|
unique:"John identifies 'another disciple' known to the high priest who helped Peter gain entry.",
|
||||||
|
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(18) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'The Trial Before Caiaphas & the Sanhedrin',
|
||||||
|
body:"The Sanhedrin assembles and seeks false testimony against Jesus. Many false witnesses come, but their testimony does not agree.",
|
||||||
|
gospels:['matt','mark'],
|
||||||
|
refs:'Matt 26:57, 59–61 · Mark 14:53, 55–59',
|
||||||
|
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'"Are You the Christ?" — The High Priest\'s Question',
|
||||||
|
body:'"I adjure you by the living God, tell us if you are the Christ, the Son of God." Jesus answered, "You have said so. But I tell you, from now on you will see the Son of Man seated at the right hand of Power."',
|
||||||
|
gospels:['matt','mark','luke'],
|
||||||
|
refs:'Matt 26:63–64 · Mark 14:61–62 · Luke 22:66–70',
|
||||||
|
unique:"Luke places a formal Sanhedrin inquiry at daybreak. Mark records Jesus' most direct 'I am' reply.",
|
||||||
|
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'The Verdict: Blasphemy — Deserving Death',
|
||||||
|
body:'The high priest tears his robes: "He has uttered blasphemy!" The assembly declares Jesus deserves death and begins spitting on him, striking him, and mocking him.',
|
||||||
|
gospels:['matt','mark','luke'],
|
||||||
|
refs:'Matt 26:65–68 · Mark 14:63–65 · Luke 22:63–65',
|
||||||
|
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:"Peter's Three Denials",
|
||||||
|
body:"As Jesus is being tried, Peter denies knowing him three times — to a servant girl, another servant, and bystanders — and the rooster crows.",
|
||||||
|
gospels:['matt','mark','luke','john'],
|
||||||
|
refs:'Matt 26:69–75 · Mark 14:66–72 · Luke 22:56–62 · John 18:17–18, 25–27',
|
||||||
|
unique:'Luke alone records that Jesus turned and looked at Peter, and Peter went out and wept bitterly.',
|
||||||
|
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(18) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:"Judas's Remorse & Death (Matthew)",
|
||||||
|
body:"Judas sees that Jesus has been condemned, brings the thirty pieces of silver back to the chief priests, and goes and hangs himself. The priests use the money to buy the Potter's Field.",
|
||||||
|
gospels:['matt'],
|
||||||
|
refs:'Matt 27:3–10',
|
||||||
|
unique:'Unique to Matthew. Acts 1:18–19 offers a parallel account of Judas\'s end.',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'Early Morning Formal Condemnation',
|
||||||
|
body:'At daybreak the chief priests and the whole council hold a formal session and bind Jesus to hand him over to Pilate.',
|
||||||
|
gospels:['matt','mark','luke'],
|
||||||
|
refs:'Matt 27:1–2 · Mark 15:1 · Luke 22:66–23:1',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
|
||||||
|
},
|
||||||
|
]},
|
||||||
|
|
||||||
|
// ── PHASE 3: ROMAN TRIAL ──────────────────────────────────────
|
||||||
|
{ phase:'roman-trial', phaseLabel:'Phase III', phaseTitle:'The Roman Trial Before Pilate', events:[
|
||||||
|
{
|
||||||
|
title:'Jesus Brought Before Pilate',
|
||||||
|
body:"The Jewish leaders bring Jesus to Pilate's headquarters. They do not enter so as not to be defiled before Passover. They accuse Jesus of misleading the nation, opposing taxes, and claiming to be King.",
|
||||||
|
gospels:['matt','mark','luke','john'],
|
||||||
|
refs:'Matt 27:2,11 · Mark 15:1–2 · Luke 23:1–2 · John 18:28–32',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(18) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'"Are You the King of the Jews?" — Pilate\'s First Question',
|
||||||
|
body:'"Are you the King of the Jews?" Jesus replies, "You have said so" — or in John\'s longer account, "My kingdom is not of this world."',
|
||||||
|
gospels:['matt','mark','luke','john'],
|
||||||
|
refs:'Matt 27:11 · Mark 15:2 · Luke 23:3 · John 18:33–37',
|
||||||
|
unique:'John records the extensive private exchange between Pilate and Jesus about kingship and truth.',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(18) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'Jesus Silent Before Accusations',
|
||||||
|
body:'As the chief priests heap on charges, Jesus gives no answer, so that Pilate is greatly amazed.',
|
||||||
|
gospels:['matt','mark'],
|
||||||
|
refs:'Matt 27:12–14 · Mark 15:3–5',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'Jesus Sent to Herod Antipas (Luke)',
|
||||||
|
body:"Learning Jesus is from Galilee, Pilate sends him to Herod, who is in Jerusalem. Herod questions Jesus at length but Jesus says nothing. Herod mocks him, dresses him in splendid clothing, and sends him back.",
|
||||||
|
gospels:['luke'],
|
||||||
|
refs:'Luke 23:6–12',
|
||||||
|
unique:'Unique to Luke. Luke notes this made Herod and Pilate friends that day.',
|
||||||
|
bsbLinks:{ luke:BSB.luke(23) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'Pilate Declares Jesus Innocent',
|
||||||
|
body:'Pilate announces he finds no guilt in Jesus, and that neither does Herod. He offers to release one prisoner according to the custom.',
|
||||||
|
gospels:['luke','john'],
|
||||||
|
refs:'Luke 23:13–16 · John 18:38–19:6',
|
||||||
|
unique:"Luke alone notes three formal declarations of innocence by Pilate.",
|
||||||
|
bsbLinks:{ luke:BSB.luke(23), john:BSB.john(18) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'Barabbas Released — Jesus Condemned',
|
||||||
|
body:'The crowd chooses to release Barabbas, a notorious rebel and murderer, instead of Jesus. The crowd shouts "Crucify him!"',
|
||||||
|
gospels:['matt','mark','luke','john'],
|
||||||
|
refs:'Matt 27:15–23 · Mark 15:6–14 · Luke 23:18–23 · John 18:39–40',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(18) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:"Pilate's Wife's Warning (Matthew)",
|
||||||
|
body:'"Have nothing to do with that righteous man, for I have suffered much over him today in a dream."',
|
||||||
|
gospels:['matt'],
|
||||||
|
refs:'Matt 27:19',
|
||||||
|
unique:'Unique to Matthew.',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:"Pilate Washes His Hands (Matthew)",
|
||||||
|
body:'Pilate takes water and washes his hands before the crowd: "I am innocent of this man\'s blood." The people respond, "His blood be on us and on our children."',
|
||||||
|
gospels:['matt'],
|
||||||
|
refs:'Matt 27:24–25',
|
||||||
|
unique:'Unique to Matthew.',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'"Behold the Man" — Ecce Homo (John)',
|
||||||
|
body:'Pilate leads Jesus out wearing the crown of thorns and a purple robe: "Behold the man!" The chief priests cry "Crucify him!"',
|
||||||
|
gospels:['john'],
|
||||||
|
refs:'John 19:4–7',
|
||||||
|
unique:'Unique to John.',
|
||||||
|
bsbLinks:{ john:BSB.john(19) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'"Son of God" Accusation Frightens Pilate (John)',
|
||||||
|
body:'When the Jews say Jesus made himself the Son of God, Pilate is even more afraid and questions Jesus again about his origin, but Jesus gives no answer.',
|
||||||
|
gospels:['john'],
|
||||||
|
refs:'John 19:8–11',
|
||||||
|
unique:"Unique to John — Jesus' declaration that Pilate's authority comes from above.",
|
||||||
|
bsbLinks:{ john:BSB.john(19) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:"The Soldiers' Mockery",
|
||||||
|
body:"Pilate's soldiers strip Jesus, put a scarlet/purple robe on him, press a crown of thorns on his head, put a reed in his right hand, kneel before him mockingly, spit on him, and strike him.",
|
||||||
|
gospels:['matt','mark','john'],
|
||||||
|
refs:'Matt 27:27–31 · Mark 15:16–20 · John 19:2–3',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), john:BSB.john(19) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'Final Condemnation — Jesus Handed Over',
|
||||||
|
body:'Pilate, seeking to release Jesus but fearful of the cry "You are no friend of Caesar!", finally hands Jesus over to be crucified.',
|
||||||
|
gospels:['matt','mark','luke','john'],
|
||||||
|
refs:'Matt 27:26 · Mark 15:15 · Luke 23:24–25 · John 19:12–16',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(19) }
|
||||||
|
},
|
||||||
|
]},
|
||||||
|
|
||||||
|
// ── PHASE 4: CRUCIFIXION ──────────────────────────────────────
|
||||||
|
{ phase:'crucifixion', phaseLabel:'Phase IV', phaseTitle:'The Way of the Cross & Crucifixion', events:[
|
||||||
|
{
|
||||||
|
title:'Jesus Carries His Cross',
|
||||||
|
body:'Jesus goes out carrying his own cross to the place called Golgotha, "The Place of a Skull."',
|
||||||
|
gospels:['matt','mark','luke','john'],
|
||||||
|
refs:'Matt 27:31–32 · Mark 15:20–21 · Luke 23:26 · John 19:17',
|
||||||
|
unique:'John uniquely emphasizes Jesus carried his own cross (echoing Isaac carrying wood in Gen 22). The Synoptics record Simon of Cyrene being compelled to carry it.',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(19) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'Simon of Cyrene Carries the Cross',
|
||||||
|
body:'Coming in from the country, Simon of Cyrene is seized and compelled to carry the cross behind Jesus.',
|
||||||
|
gospels:['matt','mark','luke'],
|
||||||
|
refs:'Matt 27:32 · Mark 15:21 · Luke 23:26',
|
||||||
|
unique:"Mark names Simon's sons, Alexander and Rufus, suggesting they were known to Mark's community.",
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'Jesus Addresses the Daughters of Jerusalem (Luke)',
|
||||||
|
body:'"Daughters of Jerusalem, do not weep for me, but weep for yourselves and for your children... For if they do these things when the wood is green, what will happen when it is dry?"',
|
||||||
|
gospels:['luke'],
|
||||||
|
refs:'Luke 23:27–31',
|
||||||
|
unique:'Unique to Luke.',
|
||||||
|
bsbLinks:{ luke:BSB.luke(23) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'The Offer of Wine Mixed with Gall/Myrrh',
|
||||||
|
body:'Before the crucifixion, soldiers offer Jesus wine mixed with gall (or myrrh) — a narcotic to dull the pain — but Jesus refuses after tasting it.',
|
||||||
|
gospels:['matt','mark'],
|
||||||
|
refs:'Matt 27:34 · Mark 15:23',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'The Crucifixion — The Third Hour',
|
||||||
|
body:'Jesus is crucified between two criminals. The soldiers cast lots for his garments, fulfilling Psalm 22:18.',
|
||||||
|
gospels:['matt','mark','luke','john'],
|
||||||
|
refs:'Matt 27:35 · Mark 15:24–25 · Luke 23:33 · John 19:18, 23–24',
|
||||||
|
unique:"John uniquely emphasizes the lot-casting fulfills Scripture and describes the tunic as seamless (echoing the high priest's garment).",
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(19) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'The Title on the Cross: "King of the Jews"',
|
||||||
|
body:'Pilate has a notice written in Hebrew, Greek, and Latin: "Jesus of Nazareth, the King of the Jews."',
|
||||||
|
gospels:['matt','mark','luke','john'],
|
||||||
|
refs:'Matt 27:37 · Mark 15:26 · Luke 23:38 · John 19:19–22',
|
||||||
|
unique:'John alone records the Jewish leaders\' protest and Pilate\'s firm reply: "What I have written, I have written."',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(19) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'The Mockery at the Cross',
|
||||||
|
body:'Passersby wag their heads: "You who would destroy the temple, save yourself!" The chief priests, scribes, and elders join in mocking.',
|
||||||
|
gospels:['matt','mark','luke'],
|
||||||
|
refs:'Matt 27:39–44 · Mark 15:29–32 · Luke 23:35–37',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'One Criminal Repents — "Remember Me" (Luke)',
|
||||||
|
body:'One criminal joins the mockery. The other rebukes him and says, "Jesus, remember me when you come into your kingdom." Jesus replies, "Today you will be with me in paradise."',
|
||||||
|
gospels:['luke'],
|
||||||
|
refs:'Luke 23:39–43',
|
||||||
|
unique:'Unique to Luke — the only death-row conversion recorded in the Gospels.',
|
||||||
|
bsbLinks:{ luke:BSB.luke(23) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:"Jesus Entrusts His Mother to the Beloved Disciple (John)",
|
||||||
|
body:'"Woman, behold your son... Behold your mother." From that hour the disciple took her into his own home.',
|
||||||
|
gospels:['john'],
|
||||||
|
refs:'John 19:25–27',
|
||||||
|
unique:'Unique to John.',
|
||||||
|
bsbLinks:{ john:BSB.john(19) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'Darkness Over the Land — Three Hours',
|
||||||
|
body:'From the sixth hour (noon) to the ninth hour (3 p.m.), darkness covers all the land.',
|
||||||
|
gospels:['matt','mark','luke'],
|
||||||
|
refs:'Matt 27:45 · Mark 15:33 · Luke 23:44',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'"My God, My God, Why Have You Forsaken Me?"',
|
||||||
|
body:'About the ninth hour Jesus cries out, "Eloi, Eloi, lema sabachthani?" — quoting Psalm 22:1. Some bystanders think he is calling for Elijah.',
|
||||||
|
gospels:['matt','mark'],
|
||||||
|
refs:'Matt 27:46–47 · Mark 15:34–35',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'"I Thirst" (John)',
|
||||||
|
body:'Jesus says "I thirst." A sponge soaked in sour wine is put on a hyssop branch and offered to him.',
|
||||||
|
gospels:['john'],
|
||||||
|
refs:'John 19:28–29',
|
||||||
|
unique:"John frames this as Jesus knowing 'all was now finished, to fulfill Scripture' (Ps 22:15; 69:21). The hyssop branch echoes the Passover lamb.",
|
||||||
|
bsbLinks:{ john:BSB.john(19) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'"Father, Forgive Them" (Luke)',
|
||||||
|
body:'"Father, forgive them, for they know not what they do."',
|
||||||
|
gospels:['luke'],
|
||||||
|
refs:'Luke 23:34',
|
||||||
|
unique:'Unique to Luke.',
|
||||||
|
bsbLinks:{ luke:BSB.luke(23) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'"It Is Finished" — Tetelestai (John)',
|
||||||
|
body:'Jesus says, "It is finished" (τετέλεσται — tetelestai, meaning "paid in full"). He bows his head and gives up his spirit.',
|
||||||
|
gospels:['john'],
|
||||||
|
refs:'John 19:30',
|
||||||
|
unique:'Unique to John — the climactic proclamation of completed redemption.',
|
||||||
|
bsbLinks:{ john:BSB.john(19) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'"Father, Into Your Hands I Commit My Spirit" (Luke)',
|
||||||
|
body:'With a loud cry, Jesus says, "Father, into your hands I commit my spirit!" (quoting Psalm 31:5), and breathes his last.',
|
||||||
|
gospels:['luke'],
|
||||||
|
refs:'Luke 23:46',
|
||||||
|
unique:'Unique to Luke.',
|
||||||
|
bsbLinks:{ luke:BSB.luke(23) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'Jesus Breathes His Last & The Temple Curtain Torn',
|
||||||
|
body:'Jesus utters a loud cry and breathes his last. The curtain of the temple is torn in two, from top to bottom.',
|
||||||
|
gospels:['matt','mark','luke'],
|
||||||
|
refs:'Matt 27:50–51 · Mark 15:37–38 · Luke 23:45–46',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'Earthquake, Rocks Split & Tombs Opened (Matthew)',
|
||||||
|
body:'The earth shakes and rocks split. Tombs are opened and many saints who had fallen asleep are raised, appearing to many in Jerusalem after the resurrection.',
|
||||||
|
gospels:['matt'],
|
||||||
|
refs:'Matt 27:51–53',
|
||||||
|
unique:'Unique to Matthew — a remarkable eschatological sign accompanying the death of Christ.',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:"The Centurion's Confession",
|
||||||
|
body:'The Roman centurion, seeing how Jesus died, declares: "Truly this man was the Son of God!" (Matt/Mark) or "Certainly this man was innocent!" (Luke).',
|
||||||
|
gospels:['matt','mark','luke'],
|
||||||
|
refs:'Matt 27:54 · Mark 15:39 · Luke 23:47',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'The Women Watching from a Distance',
|
||||||
|
body:'Many women who followed Jesus from Galilee look on from a distance, including Mary Magdalene, Mary the mother of James and Joseph, and Salome.',
|
||||||
|
gospels:['matt','mark','luke'],
|
||||||
|
refs:'Matt 27:55–56 · Mark 15:40–41 · Luke 23:49',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:"Soldiers Break No Bones — Spear Thrust (John)",
|
||||||
|
body:"To hasten death, soldiers break the legs of the two criminals. Finding Jesus already dead, a soldier pierces his side with a spear, and blood and water immediately flow out.",
|
||||||
|
gospels:['john'],
|
||||||
|
refs:'John 19:31–37',
|
||||||
|
unique:"Unique to John. He sees this fulfilling 'Not one of his bones will be broken' (Ex 12:46; Ps 34:20) and Zechariah 12:10.",
|
||||||
|
bsbLinks:{ john:BSB.john(19) }
|
||||||
|
},
|
||||||
|
]},
|
||||||
|
|
||||||
|
// ── PHASE 5: BURIAL ──────────────────────────────────────────
|
||||||
|
{ phase:'burial', phaseLabel:'Phase V', phaseTitle:'The Burial of Jesus', events:[
|
||||||
|
{
|
||||||
|
title:'Joseph of Arimathea Requests the Body',
|
||||||
|
body:"Joseph of Arimathea, a respected council member and secret disciple, goes to Pilate and asks for the body of Jesus. Pilate confirms with the centurion that Jesus is dead and grants the request.",
|
||||||
|
gospels:['matt','mark','luke','john'],
|
||||||
|
refs:'Matt 27:57–58 · Mark 15:42–45 · Luke 23:50–52 · John 19:38',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(19) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'Nicodemus Brings Spices (John)',
|
||||||
|
body:"Nicodemus, who had come to Jesus by night, brings a mixture of myrrh and aloes — about seventy-five pounds.",
|
||||||
|
gospels:['john'],
|
||||||
|
refs:'John 19:39',
|
||||||
|
unique:'Unique to John. The extravagant amount of spices honors Jesus as royalty.',
|
||||||
|
bsbLinks:{ john:BSB.john(19) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'The Body Wrapped & Laid in the Tomb',
|
||||||
|
body:'The body is wrapped in a clean linen shroud and laid in a new tomb cut from rock, in which no one had yet been buried. A large stone is rolled against the entrance.',
|
||||||
|
gospels:['matt','mark','luke','john'],
|
||||||
|
refs:'Matt 27:59–60 · Mark 15:46 · Luke 23:53 · John 19:40–42',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(19) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'The Women See Where He Is Laid',
|
||||||
|
body:'Mary Magdalene and Mary the mother of Joses observe where Jesus is laid. They return home and prepare spices and ointments.',
|
||||||
|
gospels:['matt','mark','luke'],
|
||||||
|
refs:'Matt 27:61 · Mark 15:47 · Luke 23:55–56',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'The Guard Placed at the Tomb (Matthew)',
|
||||||
|
body:"The chief priests and Pharisees ask Pilate to secure the tomb, fearing the disciples will steal the body and claim a resurrection. Pilate gives them a guard; they seal the stone.",
|
||||||
|
gospels:['matt'],
|
||||||
|
refs:'Matt 27:62–66',
|
||||||
|
unique:'Unique to Matthew — sets up the resurrection narrative in Matthew 28.',
|
||||||
|
bsbLinks:{ matt:BSB.matt(27) }
|
||||||
|
},
|
||||||
|
]},
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildTimeline() {
|
||||||
|
const container = document.getElementById('timeline');
|
||||||
|
container.innerHTML = '';
|
||||||
|
const colorMap = { matt:'var(--matt-color)', mark:'var(--mark-color)', luke:'var(--luke-color)', john:'var(--john-color)' };
|
||||||
|
|
||||||
|
data.forEach(phase => {
|
||||||
|
const phHeader = document.createElement('div');
|
||||||
|
phHeader.className = 'phase-header';
|
||||||
|
phHeader.setAttribute('data-phase', phase.phase);
|
||||||
|
phHeader.innerHTML = `<div class="phase-node"></div><span class="phase-label">${phase.phaseLabel}</span><span class="phase-title">${phase.phaseTitle}</span>`;
|
||||||
|
container.appendChild(phHeader);
|
||||||
|
|
||||||
|
phase.events.forEach(ev => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'event-card';
|
||||||
|
card.setAttribute('data-gospels', ev.gospels.join(' '));
|
||||||
|
card.setAttribute('data-phase', phase.phase);
|
||||||
|
|
||||||
|
const borderColor = ev.gospels.length === 4 ? 'var(--crimson)' : colorMap[ev.gospels[0]];
|
||||||
|
card.style.borderLeftColor = borderColor;
|
||||||
|
|
||||||
|
const badges = ev.gospels.map(g => {
|
||||||
|
const url = ev.bsbLinks && ev.bsbLinks[g];
|
||||||
|
const label = g.charAt(0).toUpperCase() + g.slice(1);
|
||||||
|
if (url) {
|
||||||
|
return `<a class="badge badge-${g}" href="${url}" target="_blank" rel="noopener" title="Open ${label} in BSB">${label} ↗</a>`;
|
||||||
|
}
|
||||||
|
return `<span class="badge badge-${g}">${label}</span>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const uniqueNote = ev.unique ? `<div class="unique-note">✦ ${ev.unique}</div>` : '';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="event-title">${ev.title}</div>
|
||||||
|
<div class="event-body">${ev.body}</div>
|
||||||
|
<div class="gospel-badges">${badges}</div>
|
||||||
|
<div class="ref-text">${ev.refs}</div>
|
||||||
|
${uniqueNote}
|
||||||
|
`;
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
document.querySelectorAll('.event-card').forEach(card => {
|
||||||
|
const cPhase = card.getAttribute('data-phase');
|
||||||
|
const cGospels = card.getAttribute('data-gospels').split(' ');
|
||||||
|
const phaseOk = activePhase === 'all' || cPhase === activePhase;
|
||||||
|
const gospelOk = cGospels.some(g => activeGospels.has(g));
|
||||||
|
card.classList.toggle('hidden', !phaseOk || !gospelOk);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.phase-header').forEach(h => {
|
||||||
|
const hPhase = h.getAttribute('data-phase');
|
||||||
|
h.style.display = (activePhase === 'all' || hPhase === activePhase) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGospel(gospel, el) {
|
||||||
|
if (activeGospels.has(gospel)) {
|
||||||
|
if (activeGospels.size === 1) return;
|
||||||
|
activeGospels.delete(gospel);
|
||||||
|
el.classList.add('dimmed');
|
||||||
|
} else {
|
||||||
|
activeGospels.add(gospel);
|
||||||
|
el.classList.remove('dimmed');
|
||||||
|
}
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterPhase(phase, btn) {
|
||||||
|
activePhase = phase;
|
||||||
|
document.querySelectorAll('.filter-btn').forEach(b => {
|
||||||
|
b.classList.remove('active-filter');
|
||||||
|
b.style.background = 'transparent';
|
||||||
|
b.style.color = 'var(--parchment-dark)';
|
||||||
|
b.style.borderColor = '#555';
|
||||||
|
});
|
||||||
|
btn.classList.add('active-filter');
|
||||||
|
btn.style.background = 'var(--gold)';
|
||||||
|
btn.style.color = 'var(--ink)';
|
||||||
|
btn.style.borderColor = 'var(--gold)';
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTimeline();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?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>
|
||||||
@@ -1,62 +1,327 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
import { hasVisitorConsent } from './server/helpers.js'
|
||||||
import path from 'node:path'
|
import { isValidAdminSession } from './server/auth.js'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { BACKUP_INTERVAL_MS } from './server/config.js'
|
||||||
|
import { state } from './server/state.js'
|
||||||
|
import {
|
||||||
|
loadHitStatsFromDisk,
|
||||||
|
loadVisitorStatsFromDisk,
|
||||||
|
loadContactSubmissionsFromDisk,
|
||||||
|
loadReplyTemplatesFromDisk,
|
||||||
|
loadReplyHistoryFromDisk,
|
||||||
|
loadQuestionsFromDisk,
|
||||||
|
loadDraftQuestionsFromDisk,
|
||||||
|
loadStudyUsersFromDisk,
|
||||||
|
loadStudyCommunityFromDisk,
|
||||||
|
loadStudyRemindersFromDisk,
|
||||||
|
loadStudyCommentsFromDisk,
|
||||||
|
loadStudyCertificatesFromDisk,
|
||||||
|
loadEpisodeScriptsFromDisk,
|
||||||
|
migrateStudyNotesIfNeeded,
|
||||||
|
loadDownloadCountsFromDisk,
|
||||||
|
loadQrCodesFromDisk,
|
||||||
|
loadEpisodePlaysFromDisk,
|
||||||
|
loadAnalyticsEventsFromDisk,
|
||||||
|
loadEmailSettingsFromDisk,
|
||||||
|
loadAuditLogFromDisk,
|
||||||
|
loadWebhooksFromDisk,
|
||||||
|
loadStudySessionsFromDisk,
|
||||||
|
queueStudySessionsWrite,
|
||||||
|
loadNewsletterIssuesFromDisk,
|
||||||
|
createBackupSnapshot,
|
||||||
|
refreshContentCaches,
|
||||||
|
queueHitStatsWrite,
|
||||||
|
} from './server/data.js'
|
||||||
|
import { logResendEmailAlignmentWarnings, sendStudyReminderEmail, sendStudyReengagementEmail, sendStudyWeeklyDigestEmail } from './server/email.js'
|
||||||
|
import {
|
||||||
|
detectBot,
|
||||||
|
sanitizeUserAgent,
|
||||||
|
shouldCountHit,
|
||||||
|
recordHit,
|
||||||
|
scheduleStudyReminders,
|
||||||
|
scheduleReengagementEmails,
|
||||||
|
scheduleWeeklyDigestEmails,
|
||||||
|
backfillCompletedStudies,
|
||||||
|
} from './server/study-helpers.js'
|
||||||
|
import { recordVisitor } from './server/routes/analytics.js'
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
// Route registrars
|
||||||
const __dirname = path.dirname(__filename)
|
import { register as registerAdminAuth } from './server/routes/admin-auth.js'
|
||||||
const DATA_DIR = path.join(__dirname, 'data')
|
import { register as registerAdminContent, runScheduledSeriesTransition } from './server/routes/admin-content.js'
|
||||||
const DATA_FILE = path.join(DATA_DIR, 'admin-content.json')
|
import { register as registerAdminAssets } from './server/routes/admin-assets.js'
|
||||||
const DIST_DIR = path.join(__dirname, 'dist')
|
import { register as registerAdminBackup } from './server/routes/admin-backup.js'
|
||||||
const INDEX_FILE = path.join(DIST_DIR, 'index.html')
|
import { register as registerAdminDashboard } from './server/routes/admin-dashboard.js'
|
||||||
|
import { register as registerStudyAuth } from './server/routes/study-auth.js'
|
||||||
|
import { register as registerStudyData } from './server/routes/study-data.js'
|
||||||
|
import { register as registerStudyAccount } from './server/routes/study-account.js'
|
||||||
|
import { register as registerStudyComments } from './server/routes/study-comments.js'
|
||||||
|
import { register as registerStudyCertificate } from './server/routes/study-certificate.js'
|
||||||
|
import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js'
|
||||||
|
import { register as registerContact } from './server/routes/contact.js'
|
||||||
|
import { register as registerInboundEmail } from './server/routes/inbound-email.js'
|
||||||
|
import { register as registerQuestions } from './server/routes/questions.js'
|
||||||
|
import { register as registerAnalytics } from './server/routes/analytics.js'
|
||||||
|
import { register as registerDownloads } from './server/routes/downloads.js'
|
||||||
|
import { register as registerEpisodes } from './server/routes/episodes.js'
|
||||||
|
import { register as registerNuggets } from './server/routes/nuggets.js'
|
||||||
|
import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js'
|
||||||
|
import { register as registerNuggetsWorkflow } from './server/routes/nuggets-workflow.js'
|
||||||
|
import { register as registerPodcastWorkflow } from './server/routes/podcast-workflow.js'
|
||||||
|
import { register as registerSeasonCheck } from './server/season-check.js'
|
||||||
|
import { warmFeedSnapshot } from './server/feed-release.js'
|
||||||
|
import { preferIpv4ForSeafile } from './server/seafile-dns.js'
|
||||||
|
import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js'
|
||||||
|
import { register as registerFeed } from './server/routes/feed.js'
|
||||||
|
import { register as registerQrCodes } from './server/routes/qr-codes.js'
|
||||||
|
import { register as registerBiblePassage } from './server/routes/bible-passage.js'
|
||||||
|
import { register as registerNewsletter } from './server/routes/newsletter.js'
|
||||||
|
import { register as registerPublic } from './server/routes/public.js'
|
||||||
|
|
||||||
|
preferIpv4ForSeafile()
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
app.use(express.json({ limit: '10mb' }))
|
app.use(express.json({ limit: '10mb' }))
|
||||||
|
const trustProxyHops = Number(process.env.TRUST_PROXY_HOPS ?? 1)
|
||||||
|
app.set('trust proxy', Number.isFinite(trustProxyHops) && trustProxyHops >= 0 ? trustProxyHops : 1)
|
||||||
|
|
||||||
app.get('/api/admin-content', async (_req, res) => {
|
app.use((_req, res, next) => {
|
||||||
try {
|
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||||
const raw = await readFile(DATA_FILE, 'utf8')
|
res.setHeader('X-Frame-Options', 'SAMEORIGIN')
|
||||||
const parsed = JSON.parse(raw)
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||||
res.json(parsed)
|
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()')
|
||||||
} catch {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
res.status(404).json({ message: 'No saved admin content file yet.' })
|
res.setHeader('Strict-Transport-Security', 'max-age=31536000')
|
||||||
}
|
}
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Security-Policy',
|
||||||
|
[
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-inline' https://www.truthforlife.org https://ajax.googleapis.com",
|
||||||
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||||
|
"font-src 'self' https://fonts.gstatic.com",
|
||||||
|
"img-src 'self' data: https:",
|
||||||
|
"media-src 'self' https:",
|
||||||
|
"frame-src https:",
|
||||||
|
"connect-src 'self' https:",
|
||||||
|
].join('; '),
|
||||||
|
)
|
||||||
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
app.put('/api/admin-content', async (req, res) => {
|
// Register API routes
|
||||||
try {
|
registerAdminAuth(app)
|
||||||
const { projects, siteContent } = req.body ?? {}
|
registerAdminContent(app)
|
||||||
|
registerAdminAssets(app)
|
||||||
|
registerAdminBackup(app)
|
||||||
|
registerAdminDashboard(app)
|
||||||
|
registerStudyAuth(app)
|
||||||
|
registerStudyData(app)
|
||||||
|
registerStudyAccount(app)
|
||||||
|
registerStudyComments(app)
|
||||||
|
registerStudyCertificate(app)
|
||||||
|
registerEpisodeScripts(app)
|
||||||
|
registerContact(app)
|
||||||
|
registerInboundEmail(app)
|
||||||
|
registerQuestions(app)
|
||||||
|
registerAnalytics(app)
|
||||||
|
registerDownloads(app)
|
||||||
|
registerEpisodes(app)
|
||||||
|
registerNuggets(app)
|
||||||
|
registerNuggetsHostUpload(app)
|
||||||
|
registerNuggetsWorkflow(app)
|
||||||
|
registerPodcastWorkflow(app)
|
||||||
|
registerSeasonCheck(app)
|
||||||
|
registerFeed(app)
|
||||||
|
registerQrCodes(app)
|
||||||
|
registerBiblePassage(app)
|
||||||
|
registerNewsletter(app)
|
||||||
|
|
||||||
if (!Array.isArray(projects) || !siteContent || typeof siteContent !== 'object') {
|
// Hit-counting middleware (must come before public routes)
|
||||||
res.status(400).json({ message: 'Invalid payload.' })
|
app.use((req, res, next) => {
|
||||||
|
if (shouldCountHit(req)) {
|
||||||
|
const ua = sanitizeUserAgent(req.get('user-agent'))
|
||||||
|
const botDetection = detectBot(ua)
|
||||||
|
recordHit(req.path, botDetection.isBot, botDetection.reason)
|
||||||
|
queueHitStatsWrite()
|
||||||
|
if (hasVisitorConsent(req) && !botDetection.isBot && !isValidAdminSession(req)) {
|
||||||
|
try {
|
||||||
|
recordVisitor(req, res)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[visitor-stats] failed to record visitor:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Public routes (robots, sitemap, static, SPA fallback)
|
||||||
|
registerPublic(app)
|
||||||
|
|
||||||
|
// JSON error responses for API routes instead of Express's default HTML page.
|
||||||
|
app.use((err, req, res, _next) => {
|
||||||
|
if (res.headersSent) return
|
||||||
|
const status = Number.isInteger(err?.status) && err.status >= 400 && err.status < 600 ? err.status : 500
|
||||||
|
if (status >= 500) console.error('[error]', req.method, req.path, err)
|
||||||
|
if (req.path.startsWith('/api/')) {
|
||||||
|
res.status(status).json({ message: status >= 500 ? 'Internal server error.' : String(err?.message ?? 'Request failed.') })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
res.status(status).type('text/plain').send(status >= 500 ? 'Internal server error.' : 'Bad request.')
|
||||||
await mkdir(DATA_DIR, { recursive: true })
|
|
||||||
await writeFile(
|
|
||||||
DATA_FILE,
|
|
||||||
JSON.stringify({ projects, siteContent, updatedAt: new Date().toISOString() }, null, 2),
|
|
||||||
'utf8',
|
|
||||||
)
|
|
||||||
|
|
||||||
res.json({ ok: true })
|
|
||||||
} catch {
|
|
||||||
res.status(500).json({ message: 'Failed to persist admin content.' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.use(express.static(DIST_DIR))
|
|
||||||
|
|
||||||
app.use(async (_req, res) => {
|
|
||||||
try {
|
|
||||||
const html = await readFile(INDEX_FILE, 'utf8')
|
|
||||||
res.type('html').send(html)
|
|
||||||
} catch {
|
|
||||||
res.status(503).send('Frontend build not found. Run "npm run build" first.')
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT ?? 4173)
|
const PORT = Number(process.env.PORT ?? 4173)
|
||||||
app.listen(PORT, () => {
|
Promise.all([
|
||||||
console.log(`Portfolio app listening on http://localhost:${PORT}`)
|
loadHitStatsFromDisk(),
|
||||||
|
loadVisitorStatsFromDisk(),
|
||||||
|
loadContactSubmissionsFromDisk(),
|
||||||
|
loadReplyTemplatesFromDisk(),
|
||||||
|
loadReplyHistoryFromDisk(),
|
||||||
|
loadQuestionsFromDisk(),
|
||||||
|
loadDraftQuestionsFromDisk(),
|
||||||
|
loadStudyUsersFromDisk(),
|
||||||
|
loadStudyCommunityFromDisk(),
|
||||||
|
loadStudyRemindersFromDisk(),
|
||||||
|
loadStudyCommentsFromDisk(),
|
||||||
|
loadStudyCertificatesFromDisk(),
|
||||||
|
loadEpisodeScriptsFromDisk(),
|
||||||
|
migrateStudyNotesIfNeeded(),
|
||||||
|
loadDownloadCountsFromDisk(),
|
||||||
|
loadQrCodesFromDisk(),
|
||||||
|
loadEpisodePlaysFromDisk(),
|
||||||
|
loadAnalyticsEventsFromDisk(),
|
||||||
|
loadEmailSettingsFromDisk(),
|
||||||
|
loadAuditLogFromDisk(),
|
||||||
|
loadWebhooksFromDisk(),
|
||||||
|
loadStudySessionsFromDisk(),
|
||||||
|
loadNewsletterIssuesFromDisk(),
|
||||||
|
refreshContentCaches(),
|
||||||
|
])
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[stats] failed to load persisted stats:', err)
|
||||||
|
})
|
||||||
|
.then(() => backfillCompletedStudies())
|
||||||
|
.then(updated => { if (updated > 0) console.log(`[study] marked ${updated} finished studies as completed`) })
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[study] completed-study backfill failed:', err)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
createBackupSnapshot('startup').catch(() => {})
|
||||||
|
setInterval(() => {
|
||||||
|
createBackupSnapshot('scheduled').catch(() => {})
|
||||||
|
}, BACKUP_INTERVAL_MS)
|
||||||
|
|
||||||
|
startNuggetsSeafileWatcher()
|
||||||
|
warmFeedSnapshot()
|
||||||
|
|
||||||
|
// Purge expired study sessions every hour
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
let pruned = false
|
||||||
|
for (const [token, session] of state.studySessions) {
|
||||||
|
if (session.expiresAt <= now) { state.studySessions.delete(token); pruned = true }
|
||||||
|
}
|
||||||
|
if (pruned) queueStudySessionsWrite()
|
||||||
|
}, 60 * 60 * 1000)
|
||||||
|
|
||||||
|
// Send study reminder emails for newly released lessons every hour
|
||||||
|
setInterval(() => {
|
||||||
|
scheduleStudyReminders(sendStudyReminderEmail).catch(err => {
|
||||||
|
console.error('[study-reminders] failed to schedule reminders:', err)
|
||||||
|
})
|
||||||
|
}, 60 * 60 * 1000)
|
||||||
|
scheduleStudyReminders(sendStudyReminderEmail).catch(err => {
|
||||||
|
console.error('[study-reminders] failed to schedule reminders:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send re-engagement emails to inactive study users every hour
|
||||||
|
setInterval(() => {
|
||||||
|
scheduleReengagementEmails(sendStudyReengagementEmail).catch(err => {
|
||||||
|
console.error('[study-reengagement] failed to schedule emails:', err)
|
||||||
|
})
|
||||||
|
}, 60 * 60 * 1000)
|
||||||
|
scheduleReengagementEmails(sendStudyReengagementEmail).catch(err => {
|
||||||
|
console.error('[study-reengagement] failed to schedule emails:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send weekly lesson digest emails every hour (only actually fires every 7 days per user)
|
||||||
|
setInterval(() => {
|
||||||
|
scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail).catch(err => {
|
||||||
|
console.error('[study-digest] failed to schedule weekly digest:', err)
|
||||||
|
})
|
||||||
|
}, 60 * 60 * 1000)
|
||||||
|
scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail).catch(err => {
|
||||||
|
console.error('[study-digest] failed to schedule weekly digest:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check for a scheduled series transition every minute
|
||||||
|
setInterval(() => {
|
||||||
|
runScheduledSeriesTransition().catch(err => {
|
||||||
|
console.error('[series-scheduler] error:', err)
|
||||||
|
})
|
||||||
|
}, 60 * 1000)
|
||||||
|
runScheduledSeriesTransition().catch(err => {
|
||||||
|
console.error('[series-scheduler] error:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
const server = app.listen(PORT, () => {
|
||||||
|
logResendEmailAlignmentWarnings()
|
||||||
|
console.log(`Siteforge listening on http://localhost:${PORT}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// On every deploy the running container gets SIGTERM'd and replaced.
|
||||||
|
// All the queueXWrite() helpers in data.js debounce disk writes onto
|
||||||
|
// per-resource promise chains — without draining those here, a redeploy
|
||||||
|
// can kill the process mid-write and silently lose the last few
|
||||||
|
// analytics events (or any other pending write) of that container's life.
|
||||||
|
function collectPendingWrites() {
|
||||||
|
return [
|
||||||
|
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.emailSettingsWritePromise,
|
||||||
|
state.auditLogWritePromise,
|
||||||
|
state.webhooksWritePromise,
|
||||||
|
state.studySessionsWritePromise,
|
||||||
|
...state.studyNotesWriteQueues.values(),
|
||||||
|
...state.studyProgressWriteQueues.values(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
let shuttingDown = false
|
||||||
|
async function gracefulShutdown(signal) {
|
||||||
|
if (shuttingDown) return
|
||||||
|
shuttingDown = true
|
||||||
|
console.log(`[shutdown] ${signal} received — closing server and flushing pending writes...`)
|
||||||
|
server.close()
|
||||||
|
const forceExit = setTimeout(() => {
|
||||||
|
console.error('[shutdown] timed out waiting for pending writes; exiting anyway')
|
||||||
|
process.exit(1)
|
||||||
|
}, 10_000)
|
||||||
|
try {
|
||||||
|
await Promise.all(collectPendingWrites())
|
||||||
|
console.log('[shutdown] all pending writes flushed')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[shutdown] error while flushing writes:', err)
|
||||||
|
} finally {
|
||||||
|
clearTimeout(forceExit)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
|
||||||
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'))
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
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 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 = 500
|
||||||
|
export const MAX_STUDY_NOTE_LENGTH = 12000
|
||||||
|
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: {},
|
||||||
|
byDay: {},
|
||||||
|
byDayReal: {},
|
||||||
|
byDayBot: {},
|
||||||
|
botReasons: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_VISITOR_STATS = {
|
||||||
|
totalVisits: 0,
|
||||||
|
uniqueVisitors: 0,
|
||||||
|
returningVisits: 0,
|
||||||
|
firstVisitAt: null,
|
||||||
|
lastVisitAt: null,
|
||||||
|
visitors: {},
|
||||||
|
ipHashIndex: {},
|
||||||
|
recentVisits: [],
|
||||||
|
geoCacheByIp: {},
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,962 @@
|
|||||||
|
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’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">“${escapeHtml(welcomeScripture)}”</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">·</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">·</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">·</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’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">“Your word is a lamp to my feet and a light to my path.” — 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// 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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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
|
||||||
|
// - 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.some(l => l?.holdUntilReleased) && 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?.holdUntilReleased || link.lane === 'general') return [link]
|
||||||
|
const season = link.lane === 'second' ? seasonB : seasonA
|
||||||
|
const item = season && link.episodeNumber ? index.get(`${season}-${link.episodeNumber}`) : null
|
||||||
|
if (!item) return []
|
||||||
|
return [{ ...link, url: link.url || item.link || '' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
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) : '',
|
||||||
|
showOnHomepage: item.showOnHomepage === true,
|
||||||
|
showNewTag: item.showNewTag === true,
|
||||||
|
newTagLabel: typeof item.newTagLabel === 'string' ? item.newTagLabel.trim().slice(0, 24) : '',
|
||||||
|
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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
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 ?? {},
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
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 } = req.body ?? {}
|
||||||
|
|
||||||
|
if (typeof displayName === 'string') {
|
||||||
|
user.displayName = displayName.trim().slice(0, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
})
|
||||||
|
|
||||||
|
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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
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(() => {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
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.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
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,
|
||||||
|
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 : '/'
|
||||||
|
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,
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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) {
|
||||||
|
const stripped = reference.replace(/^[a-z]+/i, '')
|
||||||
|
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.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,796 @@
|
|||||||
|
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'}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
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.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/admin-download-stats', requireAdminAuth, (_req, res) => {
|
||||||
|
res.json({ counts: state.downloadCounts })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
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 })) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
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 ctaBlock = `
|
||||||
|
<table border="0" width="100%" cellpadding="0" cellspacing="0" role="presentation" align="center">
|
||||||
|
<tbody><tr><td align="center" style="padding:32px 20px 48px;background-color:#080807">
|
||||||
|
<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">
|
||||||
|
<p style="margin:0 0 16px;font-family:Georgia,serif;font-size:13px;color:#6b6044">
|
||||||
|
<a href="${BASE}/newsletter" style="color:#6b6044;text-decoration:none">← Newsletter Archive</a>
|
||||||
|
</p>
|
||||||
|
<a href="${BASE}/subscribe" style="display:inline-block;padding:12px 32px;font-family:Georgia,serif;font-size:12px;letter-spacing:0.16em;text-transform:uppercase;color:#B8972A;border:1px solid #B8972A;text-decoration:none">Subscribe by Email</a>
|
||||||
|
</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 = /<\/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))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,670 @@
|
|||||||
|
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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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})` : ''}.` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { parseRssItems } from '../rss-helpers.js'
|
||||||
|
import { state } from '../state.js'
|
||||||
|
|
||||||
|
let cache = null // { url, episodes }
|
||||||
|
let cacheAt = 0
|
||||||
|
const CACHE_TTL = 30 * 60 * 1000
|
||||||
|
|
||||||
|
function feedUrl() {
|
||||||
|
return state.cachedSiteContent?.nuggets?.rssFeedUrl?.trim() ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNuggetsEpisodes() {
|
||||||
|
const url = feedUrl()
|
||||||
|
if (!url) { cache = null; return [] }
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
if (cache && cache.url === url && (now - cacheAt) < CACHE_TTL) {
|
||||||
|
return cache.episodes
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 8000)
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: controller.signal })
|
||||||
|
clearTimeout(timeout)
|
||||||
|
if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`)
|
||||||
|
const xml = await response.text()
|
||||||
|
const episodes = parseRssItems(xml)
|
||||||
|
cache = { url, episodes }
|
||||||
|
cacheAt = now
|
||||||
|
return episodes
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
// Serve stale cache for this same URL rather than a hard failure, if we have one.
|
||||||
|
if (cache && cache.url === url) return cache.episodes
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(app) {
|
||||||
|
app.get('/api/nuggets/episodes', async (_req, res) => {
|
||||||
|
const configured = Boolean(feedUrl())
|
||||||
|
try {
|
||||||
|
const episodes = await fetchNuggetsEpisodes()
|
||||||
|
res.json({ episodes: episodes.slice(0, 6), configured })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[nuggets/episodes] RSS fetch error:', err.message)
|
||||||
|
res.json({ episodes: [], configured })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/nuggets/episodes/all', async (_req, res) => {
|
||||||
|
const configured = Boolean(feedUrl())
|
||||||
|
try {
|
||||||
|
const episodes = await fetchNuggetsEpisodes()
|
||||||
|
res.json({ episodes, configured })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[nuggets/episodes/all] RSS fetch error:', err.message)
|
||||||
|
res.json({ episodes: [], configured })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { writeFile } from '../atomic-write.js'
|
||||||
|
import { requireAdminAuth } from '../auth.js'
|
||||||
|
import { state } from '../state.js'
|
||||||
|
import { DATA_DIR } from '../config.js'
|
||||||
|
import { scriptKey } from '../episode-script-keys.js'
|
||||||
|
import { fetchAllEpisodes } from './episodes.js'
|
||||||
|
|
||||||
|
const WORKFLOW_FILE = path.join(DATA_DIR, 'podcast-workflow.json')
|
||||||
|
|
||||||
|
const STEPS = ['readScript', 'record', 'edit', 'upload']
|
||||||
|
|
||||||
|
export async function loadPodcastWorkflow() {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
|
||||||
|
return { planned: Array.isArray(data.planned) ? data.planned : [], items: data.items ?? {}, laneOverrides: data.laneOverrides ?? {}, laneSeasons: data.laneSeasons ?? {} }
|
||||||
|
} catch {
|
||||||
|
return { planned: [], items: {}, laneOverrides: {}, laneSeasons: {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = data => writeFile(WORKFLOW_FILE, JSON.stringify(data, null, 2), 'utf8')
|
||||||
|
|
||||||
|
function emptyState() {
|
||||||
|
return {
|
||||||
|
steps: Object.fromEntries(STEPS.map(s => [s, null])),
|
||||||
|
notes: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lane's season comes from the workflow page's own setting, falling back to Current Series.
|
||||||
|
export function laneSeasonOf(lane, wf, content = state.cachedSiteContent ?? {}) {
|
||||||
|
const saved = wf.laneSeasons?.[lane]
|
||||||
|
if (/^\d+$/.test(String(saved ?? ''))) return String(saved)
|
||||||
|
const fallback = lane === 'A' ? content.seriesSeason : content.secondSeries?.season
|
||||||
|
return fallback ? String(fallback) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function laneFor(season, wf) {
|
||||||
|
const s = String(season ?? '')
|
||||||
|
if (s && s === laneSeasonOf('A', wf)) return 'A'
|
||||||
|
if (s && s === laneSeasonOf('B', wf)) return 'B'
|
||||||
|
return 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
const normTitle = t => String(t ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
|
||||||
|
const plannedKey = p => scriptKey(p.season, p.episodeNumber)
|
||||||
|
|
||||||
|
function toIsoDate(pubDate) {
|
||||||
|
const d = new Date(pubDate)
|
||||||
|
return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(app) {
|
||||||
|
app.get('/api/admin/podcast-workflow', requireAdminAuth, async (_req, res) => {
|
||||||
|
const wf = await loadPodcastWorkflow()
|
||||||
|
let feed = []
|
||||||
|
let feedOk = true
|
||||||
|
try {
|
||||||
|
feed = await fetchAllEpisodes()
|
||||||
|
} catch {
|
||||||
|
feedOk = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const byKey = new Map()
|
||||||
|
const upsert = (key, patch) => byKey.set(key, { ...(byKey.get(key) ?? {}), key, ...patch })
|
||||||
|
|
||||||
|
for (const p of wf.planned) {
|
||||||
|
const key = plannedKey(p)
|
||||||
|
const prev = byKey.get(key)
|
||||||
|
upsert(key, { season: p.season, episodeNumber: p.episodeNumber, title: prev?.title || p.title || '', planned: true })
|
||||||
|
}
|
||||||
|
for (const item of feed) {
|
||||||
|
const hasNum = /^\d+$/.test(item.episode)
|
||||||
|
let key
|
||||||
|
if (item.season && hasNum) {
|
||||||
|
key = scriptKey(item.season, item.episode)
|
||||||
|
} else {
|
||||||
|
// No season in the feed: pair with a script/planned episode of the same title, else keep it on its own.
|
||||||
|
const t = normTitle(item.title)
|
||||||
|
const match = [...byKey.values()].find(e => !e.released && normTitle(e.title) === t)
|
||||||
|
key = match ? match.key : `t:${t}`
|
||||||
|
}
|
||||||
|
const prev = byKey.get(key)
|
||||||
|
upsert(key, {
|
||||||
|
season: item.season || prev?.season || '',
|
||||||
|
episodeNumber: hasNum ? item.episode : (prev?.episodeNumber ?? ''),
|
||||||
|
title: item.title || prev?.title || '',
|
||||||
|
released: true,
|
||||||
|
releaseDate: toIsoDate(item.pubDate),
|
||||||
|
spotifyUrl: item.link || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...byKey.values()].map(e => ({
|
||||||
|
...e,
|
||||||
|
lane: wf.laneOverrides[e.key] ?? laneFor(e.season, wf),
|
||||||
|
laneOverride: wf.laneOverrides[e.key] ?? '',
|
||||||
|
released: Boolean(e.released),
|
||||||
|
state: { ...emptyState(), ...(wf.items[e.key] ?? {}) },
|
||||||
|
}))
|
||||||
|
items.sort((a, b) => (Number(a.season) || 0) - (Number(b.season) || 0) || (Number(a.episodeNumber) || 0) - (Number(b.episodeNumber) || 0))
|
||||||
|
res.json({ items, feedOk, laneSeasons: { A: laneSeasonOf('A', wf), B: laneSeasonOf('B', wf) } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start the workflow for a new episode. Name, season and episode number are all required
|
||||||
|
// so the episode lands in the right lane and matches the feed once it is released.
|
||||||
|
app.post('/api/admin/podcast-workflow/planned', requireAdminAuth, async (req, res) => {
|
||||||
|
const season = String(req.body?.season ?? '').trim()
|
||||||
|
const episodeNumber = String(req.body?.episodeNumber ?? '').trim()
|
||||||
|
const title = typeof req.body?.title === 'string' ? req.body.title.trim().slice(0, 160) : ''
|
||||||
|
if (!title) {
|
||||||
|
res.status(400).json({ message: 'Episode name is required.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/^\d+$/.test(season) || !/^\d+$/.test(episodeNumber)) {
|
||||||
|
res.status(400).json({ message: 'Season and episode number must be whole numbers.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const wf = await loadPodcastWorkflow()
|
||||||
|
const key = scriptKey(season, episodeNumber)
|
||||||
|
if (wf.planned.some(p => plannedKey(p) === key)) {
|
||||||
|
res.status(409).json({ message: `Season ${season} episode ${episodeNumber} has already been started.` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wf.planned.push({ season, episodeNumber, title })
|
||||||
|
await save(wf)
|
||||||
|
res.json({ ok: true, key })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/admin/podcast-workflow/planned/:key', requireAdminAuth, async (req, res) => {
|
||||||
|
const wf = await loadPodcastWorkflow()
|
||||||
|
wf.planned = wf.planned.filter(p => plannedKey(p) !== req.params.key)
|
||||||
|
delete wf.items[req.params.key]
|
||||||
|
delete wf.laneOverrides[req.params.key]
|
||||||
|
await save(wf)
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set the season number each lane looks for ('' goes back to the Current Series value).
|
||||||
|
app.put('/api/admin/podcast-workflow-lane-seasons', requireAdminAuth, async (req, res) => {
|
||||||
|
const wf = await loadPodcastWorkflow()
|
||||||
|
for (const lane of ['A', 'B']) {
|
||||||
|
const v = req.body?.[lane]
|
||||||
|
if (v === undefined) continue
|
||||||
|
const str = String(v).trim()
|
||||||
|
if (str === '') delete wf.laneSeasons[lane]
|
||||||
|
else if (/^\d+$/.test(str)) wf.laneSeasons[lane] = str
|
||||||
|
else { res.status(400).json({ message: 'Season must be a whole number.' }); return }
|
||||||
|
}
|
||||||
|
await save(wf)
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Assign an episode to a lane by hand ('auto' clears the override and goes back to season matching).
|
||||||
|
app.put('/api/admin/podcast-workflow/:key/lane', requireAdminAuth, async (req, res) => {
|
||||||
|
const lane = req.body?.lane
|
||||||
|
if (!['A', 'B', 'other', 'auto'].includes(lane)) {
|
||||||
|
res.status(400).json({ message: 'Lane must be A, B, other or auto.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const wf = await loadPodcastWorkflow()
|
||||||
|
if (lane === 'auto') delete wf.laneOverrides[req.params.key]
|
||||||
|
else wf.laneOverrides[req.params.key] = lane
|
||||||
|
await save(wf)
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/api/admin/podcast-workflow/:key', requireAdminAuth, async (req, res) => {
|
||||||
|
const { key } = req.params
|
||||||
|
const wf = await loadPodcastWorkflow()
|
||||||
|
const prev = { ...emptyState(), ...(wf.items[key] ?? {}) }
|
||||||
|
const next = { ...prev, steps: { ...prev.steps } }
|
||||||
|
const body = req.body ?? {}
|
||||||
|
if (body.steps && typeof body.steps === 'object') {
|
||||||
|
for (const s of STEPS) {
|
||||||
|
if (typeof body.steps[s] === 'boolean') {
|
||||||
|
next.steps[s] = body.steps[s] ? (prev.steps[s] ?? new Date().toISOString()) : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof body.notes === 'string') next.notes = body.notes.slice(0, 5000)
|
||||||
|
wf.items[key] = next
|
||||||
|
await save(wf)
|
||||||
|
res.json({ ok: true, state: next })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import { applyFeedRelease } from '../feed-release.js'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { escapeHtml, escapeXml, injectSeoIntoHtml, normalizeSitemapPath } from '../helpers.js'
|
||||||
|
import {
|
||||||
|
DIST_DIR,
|
||||||
|
DIST_IMAGES_DIR,
|
||||||
|
PUBLIC_IMAGES_DIR,
|
||||||
|
UPLOADS_DIR,
|
||||||
|
INDEX_FILE,
|
||||||
|
DEFAULT_SEO,
|
||||||
|
APP_VERSION,
|
||||||
|
GIT_COMMIT,
|
||||||
|
DATA_FILE,
|
||||||
|
} from '../config.js'
|
||||||
|
import { state } from '../state.js'
|
||||||
|
import { loadSiteContentFile } from '../data.js'
|
||||||
|
import { sanitizeRedirectRules } from '../study-helpers.js'
|
||||||
|
import { fetchAllEpisodes } from './episodes.js'
|
||||||
|
import { fetchNuggetsEpisodes } from './nuggets.js'
|
||||||
|
import { BIBLE_BOOKS } from '../../shared/bible-books.js'
|
||||||
|
|
||||||
|
// Build a regex that matches scripture references like "Titus 1:5-9" or "1 Cor 13:4-7"
|
||||||
|
// out of plain text. Sorted longest-first so "1 Corinthians" beats "1 Cor".
|
||||||
|
const _refPatterns = []
|
||||||
|
for (const b of BIBLE_BOOKS) {
|
||||||
|
_refPatterns.push(b.name, ...(b.aliases ?? []))
|
||||||
|
}
|
||||||
|
_refPatterns.sort((a, z) => z.length - a.length)
|
||||||
|
const _bookPart = _refPatterns.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
|
||||||
|
const SCRIPTURE_REF_RE = new RegExp(
|
||||||
|
`(${_bookPart})\\s+(\\d+):(\\d+)(?:-((?:\\d+):)?(\\d+))?`,
|
||||||
|
'gi',
|
||||||
|
)
|
||||||
|
|
||||||
|
function extractScriptureRefs(text) {
|
||||||
|
const refs = []
|
||||||
|
const seen = new Set()
|
||||||
|
for (const m of String(text || '').matchAll(SCRIPTURE_REF_RE)) {
|
||||||
|
const ref = m[0].trim()
|
||||||
|
const key = ref.toLowerCase()
|
||||||
|
if (!seen.has(key)) { seen.add(key); refs.push(ref) }
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(app) {
|
||||||
|
const SALVATION_INDEX_FILE = path.join(DIST_DIR, 'salvation', 'index.html')
|
||||||
|
|
||||||
|
app.get('/api/version', (_req, res) => {
|
||||||
|
res.json({ version: APP_VERSION, commit: GIT_COMMIT })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/robots.txt', async (_req, res) => {
|
||||||
|
let content = state.cachedSiteContent
|
||||||
|
if (!content) {
|
||||||
|
try {
|
||||||
|
const parsed = await loadSiteContentFile(DATA_FILE)
|
||||||
|
content = parsed.siteContent
|
||||||
|
} catch { content = {} }
|
||||||
|
}
|
||||||
|
content = await applyFeedRelease(content)
|
||||||
|
|
||||||
|
const seo = content?.seo ?? DEFAULT_SEO
|
||||||
|
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
|
||||||
|
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
|
||||||
|
|
||||||
|
res.type('text/plain').send(
|
||||||
|
['User-agent: *', 'Allow: /', 'Disallow: /salvation', `Sitemap: ${root}/sitemap.xml`].join('\n'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/sitemap.xml', async (_req, res) => {
|
||||||
|
let content = state.cachedSiteContent
|
||||||
|
if (!content) {
|
||||||
|
try {
|
||||||
|
const parsed = await loadSiteContentFile(DATA_FILE)
|
||||||
|
content = parsed.siteContent
|
||||||
|
} catch { content = {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
const seo = content?.seo ?? DEFAULT_SEO
|
||||||
|
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
|
||||||
|
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
|
||||||
|
const paths = Array.isArray(seo.sitemapPaths) && seo.sitemapPaths.length > 0
|
||||||
|
? seo.sitemapPaths
|
||||||
|
: DEFAULT_SEO.sitemapPaths
|
||||||
|
|
||||||
|
const urls = paths
|
||||||
|
.map(item => normalizeSitemapPath(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(item => `${root}${item}`)
|
||||||
|
|
||||||
|
// Dynamic study lesson URLs
|
||||||
|
const studies = Array.isArray(state.cachedSiteContent?.studies) ? state.cachedSiteContent.studies : []
|
||||||
|
const now = new Date()
|
||||||
|
for (const study of studies) {
|
||||||
|
if (!study.slug || !Array.isArray(study.sections)) continue
|
||||||
|
for (const section of study.sections) {
|
||||||
|
if (section.id && section.releasedAt && new Date(section.releasedAt) <= now) {
|
||||||
|
urls.push(`${root}/study/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Dynamic episode highlight URLs
|
||||||
|
const highlights = [
|
||||||
|
...(Array.isArray(state.cachedSiteContent?.podcastFeaturedLinks) ? state.cachedSiteContent.podcastFeaturedLinks : []),
|
||||||
|
...(Array.isArray(state.cachedSiteContent?.finishedBooks) ? state.cachedSiteContent.finishedBooks.flatMap(book => (Array.isArray(book?.highlights) ? book.highlights : [])) : []),
|
||||||
|
]
|
||||||
|
for (const ep of highlights) {
|
||||||
|
if (ep.id) urls.push(`${root}/episodes/${encodeURIComponent(ep.id)}`)
|
||||||
|
}
|
||||||
|
// Newsletter archive + published issues
|
||||||
|
urls.push(`${root}/newsletter`)
|
||||||
|
for (const issue of state.newsletterIssues) {
|
||||||
|
if (issue.slug && issue.public !== false) urls.push(`${root}/newsletter/${encodeURIComponent(issue.slug)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const xml = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||||
|
...urls.map(url => ` <url><loc>${escapeXml(url)}</loc></url>`),
|
||||||
|
'</urlset>',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
res.type('application/xml').send(xml)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Redirect rules middleware
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const rules = sanitizeRedirectRules(state.cachedSiteContent?.redirects)
|
||||||
|
const match = rules.find(rule => rule.path === req.path)
|
||||||
|
if (!match) { next(); return }
|
||||||
|
res.redirect(match.statusCode === 302 ? 302 : 301, match.target)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
app.use('/images', express.static(DIST_IMAGES_DIR))
|
||||||
|
app.use('/images', express.static(PUBLIC_IMAGES_DIR))
|
||||||
|
app.use('/uploads', express.static(UPLOADS_DIR))
|
||||||
|
|
||||||
|
// Explicitly serve salvation page on both paths without relying on slash redirects.
|
||||||
|
app.get(['/salvation', '/salvation/'], (_req, res, next) => {
|
||||||
|
res.sendFile(SALVATION_INDEX_FILE, err => {
|
||||||
|
if (err) next()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Social share stub for questions
|
||||||
|
app.get('/questions/share/:id', (req, res) => {
|
||||||
|
const id = req.params.id
|
||||||
|
if (!id || !/^[\w-]{1,120}$/.test(id)) {
|
||||||
|
res.redirect(302, '/questions'); return
|
||||||
|
}
|
||||||
|
// Same source as /api/questions, so a freshly published answer's share link works straight away
|
||||||
|
const sourceQuestions = state.draftQuestions ?? state.questions
|
||||||
|
const question = sourceQuestions.find(q => q.id === id && q.isApproved === true && q.answer)
|
||||||
|
if (!question) {
|
||||||
|
res.redirect(302, '/questions'); return
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = 'https://versebyversewithnate.us'
|
||||||
|
const canonicalUrl = `${BASE}/questions#qa-${encodeURIComponent(id)}`
|
||||||
|
const shareUrl = `${BASE}/questions/share/${encodeURIComponent(id)}`
|
||||||
|
const ogTitle = escapeHtml(question.question.length > 100
|
||||||
|
? `${question.question.slice(0, 97)}…`
|
||||||
|
: question.question)
|
||||||
|
const answerSnippet = question.answer.replace(/\n+/g, ' ').trim()
|
||||||
|
const ogDescription = escapeHtml(answerSnippet.length > 200
|
||||||
|
? `${answerSnippet.slice(0, 197)}…`
|
||||||
|
: answerSnippet)
|
||||||
|
const ogImage = `${BASE}/images/banner.png`
|
||||||
|
|
||||||
|
res.type('html').send(`<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<title>${ogTitle} — Verse by Verse with Nate</title>
|
||||||
|
<meta name="description" content="${ogDescription}"/>
|
||||||
|
<meta property="og:type" content="article"/>
|
||||||
|
<meta property="og:site_name" content="Verse by Verse with Nate"/>
|
||||||
|
<meta property="og:url" content="${escapeHtml(shareUrl)}"/>
|
||||||
|
<meta property="og:title" content="${ogTitle}"/>
|
||||||
|
<meta property="og:description" content="${ogDescription}"/>
|
||||||
|
<meta property="og:image" content="${escapeHtml(ogImage)}"/>
|
||||||
|
<meta property="og:image:alt" content="${ogTitle}"/>
|
||||||
|
<meta name="twitter:card" content="summary_large_image"/>
|
||||||
|
<meta name="twitter:title" content="${ogTitle}"/>
|
||||||
|
<meta name="twitter:description" content="${ogDescription}"/>
|
||||||
|
<meta name="twitter:image" content="${escapeHtml(ogImage)}"/>
|
||||||
|
<link rel="canonical" href="${escapeHtml(shareUrl)}"/>
|
||||||
|
<meta http-equiv="refresh" content="0;url=${escapeHtml(canonicalUrl)}"/>
|
||||||
|
<script>location.replace(${JSON.stringify(canonicalUrl)})</script>
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
</html>`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Scripture index — every passage covered across released study lessons and podcast episodes
|
||||||
|
app.get('/api/public/scripture-index', async (_req, res) => {
|
||||||
|
const content = await applyFeedRelease(state.cachedSiteContent)
|
||||||
|
const allStudies = Array.isArray(content?.studies) ? content.studies : []
|
||||||
|
const allEpisodes = [
|
||||||
|
...(Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []),
|
||||||
|
...(Array.isArray(content?.finishedBooks) ? content.finishedBooks.flatMap(book => (Array.isArray(book?.highlights) ? book.highlights : [])) : []),
|
||||||
|
]
|
||||||
|
const cutoff = new Date()
|
||||||
|
const entries = []
|
||||||
|
|
||||||
|
for (const s of allStudies) {
|
||||||
|
if (!s.slug || !Array.isArray(s.sections)) continue
|
||||||
|
const bookName = s.bibleBook || s.slug
|
||||||
|
for (const sec of s.sections) {
|
||||||
|
if (!sec.id || !sec.reference || !sec.releasedAt) continue
|
||||||
|
if (new Date(sec.releasedAt) > cutoff) continue
|
||||||
|
entries.push({
|
||||||
|
reference: `${bookName} ${sec.reference}`,
|
||||||
|
sourceType: 'study',
|
||||||
|
sourceTitle: s.title || s.slug,
|
||||||
|
sectionTitle: sec.title || '',
|
||||||
|
url: `/study/${s.slug}/${sec.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ep of allEpisodes) {
|
||||||
|
if (!ep.id || !Array.isArray(ep.showNotesScripture)) continue
|
||||||
|
for (const ref of ep.showNotesScripture) {
|
||||||
|
if (!ref || !ref.trim()) continue
|
||||||
|
entries.push({
|
||||||
|
reference: ref.trim(),
|
||||||
|
sourceType: 'episode',
|
||||||
|
sourceTitle: ep.episodeNumber ? `Episode ${ep.episodeNumber}: ${ep.title}` : (ep.title || 'Episode'),
|
||||||
|
url: `/episodes/${ep.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract scripture refs from main podcast episode descriptions
|
||||||
|
try {
|
||||||
|
const mainEps = await fetchAllEpisodes()
|
||||||
|
for (const ep of mainEps) {
|
||||||
|
if (!ep.description) continue
|
||||||
|
const refs = extractScriptureRefs(ep.description)
|
||||||
|
for (const ref of refs) {
|
||||||
|
const title = ep.episode ? `Episode ${ep.episode}: ${ep.title}` : ep.title
|
||||||
|
entries.push({ reference: ref, sourceType: 'episode', sourceTitle: title, url: ep.link || '/episodes' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* keep going if RSS is down */ }
|
||||||
|
|
||||||
|
// Extract scripture refs from Nuggets episode descriptions
|
||||||
|
try {
|
||||||
|
const nuggetsEps = await fetchNuggetsEpisodes()
|
||||||
|
for (const ep of nuggetsEps) {
|
||||||
|
if (!ep.description) continue
|
||||||
|
const refs = extractScriptureRefs(ep.description)
|
||||||
|
for (const ref of refs) {
|
||||||
|
const title = ep.episode ? `Nuggets Ep. ${ep.episode}: ${ep.title}` : ep.title
|
||||||
|
entries.push({ reference: ref, sourceType: 'episode', sourceTitle: title, url: '/nuggets' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* keep going if RSS is down */ }
|
||||||
|
|
||||||
|
res.json({ entries })
|
||||||
|
})
|
||||||
|
|
||||||
|
// SPA static files and fallback
|
||||||
|
app.use(express.static(DIST_DIR))
|
||||||
|
|
||||||
|
app.use(async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const html = await readFile(INDEX_FILE, 'utf8')
|
||||||
|
res.type('html').send(injectSeoIntoHtml(html, state.cachedSiteContent))
|
||||||
|
} catch {
|
||||||
|
res.status(503).send('Frontend build not found. Run "npm run build" first.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { requireAdminAuth } from '../auth.js'
|
||||||
|
import { state } from '../state.js'
|
||||||
|
import { queueQrCodesWrite } from '../data.js'
|
||||||
|
|
||||||
|
const MAX_SCANS = 5000
|
||||||
|
|
||||||
|
export function register(app) {
|
||||||
|
// Public redirect — logs the scan, then redirects to destination
|
||||||
|
app.get('/qr/:slug', (req, res) => {
|
||||||
|
const { slug } = req.params
|
||||||
|
const code = state.qrCodes.find(c => c.slug === slug && c.active !== false)
|
||||||
|
if (!code) { res.status(404).send('QR code not found.'); return }
|
||||||
|
|
||||||
|
const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress || 'unknown'
|
||||||
|
const scan = {
|
||||||
|
id: randomUUID(),
|
||||||
|
qrId: code.id,
|
||||||
|
slug,
|
||||||
|
scannedAt: new Date().toISOString(),
|
||||||
|
ip,
|
||||||
|
userAgent: req.headers['user-agent'] || '',
|
||||||
|
}
|
||||||
|
state.qrScans.unshift(scan)
|
||||||
|
if (state.qrScans.length > MAX_SCANS) state.qrScans.length = MAX_SCANS
|
||||||
|
queueQrCodesWrite()
|
||||||
|
|
||||||
|
res.redirect(302, code.destination)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Admin API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/admin/qr-codes', requireAdminAuth, (_req, res) => {
|
||||||
|
const codesWithCounts = state.qrCodes.map(code => ({
|
||||||
|
...code,
|
||||||
|
scanCount: state.qrScans.filter(s => s.qrId === code.id).length,
|
||||||
|
}))
|
||||||
|
res.json({ codes: codesWithCounts, scans: state.qrScans.slice(0, 200) })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/admin/qr-codes', requireAdminAuth, (req, res) => {
|
||||||
|
const label = typeof req.body?.label === 'string' ? req.body.label.trim().slice(0, 100) : ''
|
||||||
|
const slug = typeof req.body?.slug === 'string' ? req.body.slug.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 80) : ''
|
||||||
|
const destination = typeof req.body?.destination === 'string' ? req.body.destination.trim() : ''
|
||||||
|
|
||||||
|
if (!label) { res.status(400).json({ message: 'Label is required.' }); return }
|
||||||
|
if (!slug || !/^[a-z0-9][a-z0-9-]*$/.test(slug)) { res.status(400).json({ message: 'Slug must start with a letter or digit and contain only lowercase letters, digits, and hyphens.' }); return }
|
||||||
|
if (!destination.startsWith('http')) { res.status(400).json({ message: 'Destination must be a URL.' }); return }
|
||||||
|
if (state.qrCodes.some(c => c.slug === slug)) { res.status(409).json({ message: 'A QR code with this slug already exists.' }); return }
|
||||||
|
|
||||||
|
const code = { id: randomUUID(), slug, label, destination, active: true, createdAt: new Date().toISOString() }
|
||||||
|
state.qrCodes.push(code)
|
||||||
|
queueQrCodesWrite()
|
||||||
|
res.json({ ok: true, code })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch('/api/admin/qr-codes/:id', requireAdminAuth, (req, res) => {
|
||||||
|
const code = state.qrCodes.find(c => c.id === req.params.id)
|
||||||
|
if (!code) { res.status(404).json({ message: 'QR code not found.' }); return }
|
||||||
|
|
||||||
|
if (typeof req.body?.label === 'string') code.label = req.body.label.trim().slice(0, 100)
|
||||||
|
if (typeof req.body?.destination === 'string') {
|
||||||
|
if (!req.body.destination.trim().startsWith('http')) { res.status(400).json({ message: 'Destination must be a URL.' }); return }
|
||||||
|
code.destination = req.body.destination.trim()
|
||||||
|
}
|
||||||
|
if (typeof req.body?.active === 'boolean') code.active = req.body.active
|
||||||
|
|
||||||
|
queueQrCodesWrite()
|
||||||
|
res.json({ ok: true, code })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/admin/qr-codes/:id', requireAdminAuth, (req, res) => {
|
||||||
|
const idx = state.qrCodes.findIndex(c => c.id === req.params.id)
|
||||||
|
if (idx === -1) { res.status(404).json({ message: 'QR code not found.' }); return }
|
||||||
|
|
||||||
|
const { id } = state.qrCodes[idx]
|
||||||
|
state.qrCodes.splice(idx, 1)
|
||||||
|
state.qrScans = state.qrScans.filter(s => s.qrId !== id)
|
||||||
|
queueQrCodesWrite()
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/admin/qr-codes/:id/scans', requireAdminAuth, (req, res) => {
|
||||||
|
state.qrScans = state.qrScans.filter(s => s.qrId !== req.params.id)
|
||||||
|
queueQrCodesWrite()
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
}
|
||||||