Compare commits
No files matched your search
@@ -7,6 +7,20 @@
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173,
|
||||
"autoPort": false
|
||||
},
|
||||
{
|
||||
"name": "study-app-alt",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "5174"],
|
||||
"port": 5174,
|
||||
"autoPort": false
|
||||
},
|
||||
{
|
||||
"name": "study-app-server",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev:server"],
|
||||
"port": 3001,
|
||||
"autoPort": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.git
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
REGISTRY: git.necloud.us
|
||||
IMAGE_NAME: nate/study-bible-project
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea container registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: nate@versebyversewithnate.us
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=sha-,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||
@@ -9,3 +9,15 @@ coverage/
|
||||
.vite.log
|
||||
.api.pid
|
||||
.api.log
|
||||
.DS_Store
|
||||
.env
|
||||
.session-secret
|
||||
server/data/backups/
|
||||
|
||||
# Playwright
|
||||
e2e/.auth.json
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# a local demo video, not part of the app
|
||||
demo/
|
||||
@@ -2,6 +2,7 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++ && npm install -g npm@11
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
@@ -12,6 +13,7 @@ RUN npm run build
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++ && npm install -g npm@11
|
||||
|
||||
# Copy package files and install production deps only
|
||||
COPY package*.json ./
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# Bible Study App
|
||||
|
||||
A self-hosted Bible study tool with notes, projects, and user accounts.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Node.js v18 or higher** — [nodejs.org](https://nodejs.org/)
|
||||
- **npm** (bundled with Node.js)
|
||||
- **GCC / make** (only needed for production/server installs — required to build the `better-sqlite3` native module)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nmemmert/study-bible-project.git
|
||||
cd study-bible-project
|
||||
bash setup.sh
|
||||
```
|
||||
|
||||
`setup.sh` installs dependencies, builds the optimized frontend, and runs one
|
||||
production server (frontend + API) on **http://localhost:5173**. Set `APP_PORT`
|
||||
to use a different port. If the build fails, the previously running server is
|
||||
left untouched. `bash restart.sh` pulls the latest code and does the same.
|
||||
|
||||
**Stop / logs:**
|
||||
```bash
|
||||
kill $(cat .api.pid)
|
||||
tail -f .api.log
|
||||
```
|
||||
|
||||
### Development mode (hot reload)
|
||||
|
||||
```bash
|
||||
bash setup.sh --dev
|
||||
```
|
||||
|
||||
Starts the API on http://localhost:3001 and the Vite dev server on
|
||||
http://localhost:5173. Dev mode is much slower, so don't use it for real users.
|
||||
|
||||
---
|
||||
|
||||
## Optional keys (set in `.env` or the environment)
|
||||
|
||||
| Variable | What it turns on |
|
||||
| --- | --- |
|
||||
| `SESSION_SECRET` | A long random string that signs login cookies. Optional: if unset in production, the server makes one and keeps it in `DATA_DIR/.session-secret`. |
|
||||
| `ADMIN_EMAIL` | The one admin account. |
|
||||
| `ESV_API_KEY`, `API_BIBLE_KEY` | ESV, NLT, CSB and NASB in the Reader. |
|
||||
| `IQBIBLE_API_KEY` | IQ Bible: more commentaries, lexicons (BDB, LSJ, Abbott-Smith), places, topics, people, textual notes, Gospel parallels, daily verse and Spurgeon devotional, plan calendar export, more audio narrations, and background for podcast prompts. Hidden when unset. |
|
||||
| `IQBIBLE_CACHE_HOURS` | Leave unset unless IQ Bible has given written permission to cache non-verse data; only verse/chapter text is cached by default (24h), as their terms require. |
|
||||
| `BACKUP_KEEP`, `BACKUP_DISABLED=1` | The server saves a snapshot of the whole database once a day to `DATA_DIR/backups` (default: keep 7). Restore with `scripts/restore-data.sh`. |
|
||||
| `ALLOW_PRIVATE_NTFY=1` | Lets reading reminders use an ntfy server on your own network (blocked by default). |
|
||||
|
||||
## Production Install (Ubuntu / systemd)
|
||||
|
||||
Installs the app as a persistent systemd service under `/opt/study-app`.
|
||||
|
||||
```bash
|
||||
# Install Node.js 20 (if not already installed)
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
|
||||
# Install build tools (required for better-sqlite3)
|
||||
sudo apt-get install -y build-essential python3
|
||||
|
||||
# Clone and install
|
||||
git clone https://github.com/nmemmert/study-bible-project.git
|
||||
cd study-bible-project
|
||||
sudo bash deploy/install.sh
|
||||
```
|
||||
|
||||
The installer will:
|
||||
1. Install Node.js dependencies and build the frontend
|
||||
2. Create a `study-app` system user
|
||||
3. Register and start a systemd service
|
||||
|
||||
**Useful commands after install:**
|
||||
```bash
|
||||
systemctl status study-app # Check service status
|
||||
journalctl -u study-app -f # Stream logs
|
||||
systemctl restart study-app # Restart after an update
|
||||
```
|
||||
|
||||
**Open the firewall port (if needed):**
|
||||
```bash
|
||||
sudo ufw allow 3001/tcp
|
||||
sudo ufw reload
|
||||
```
|
||||
|
||||
The app listens on **port 3001** by default. Set the `PORT` environment variable in the systemd unit to change it.
|
||||
|
||||
---
|
||||
|
||||
## Updating (Production)
|
||||
|
||||
```bash
|
||||
cd /path/to/study-bible-project
|
||||
git pull
|
||||
sudo bash deploy/install.sh
|
||||
```
|
||||
|
||||
The installer preserves the session secret across updates so logged-in users are not signed out.
|
||||
|
||||
---
|
||||
|
||||
## Migrating to a New Machine
|
||||
|
||||
**On the old machine — create a backup:**
|
||||
```bash
|
||||
npm run backup
|
||||
```
|
||||
This creates `study-app-backup_<timestamp>.zip` in the project root.
|
||||
|
||||
**On the new machine — restore after cloning and installing:**
|
||||
|
||||
Development:
|
||||
```bash
|
||||
npm run restore study-app-backup_<timestamp>.zip
|
||||
```
|
||||
|
||||
Production (after `sudo bash deploy/install.sh` completes):
|
||||
```bash
|
||||
sudo bash scripts/restore-data.sh study-app-backup_<timestamp>.zip --production
|
||||
```
|
||||
|
||||
See [scripts/backup-data.sh](scripts/backup-data.sh) and [scripts/restore-data.sh](scripts/restore-data.sh) for details.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── server/ # Express API + SQLite database
|
||||
│ └── data/ # projects.db lives here (gitignored)
|
||||
├── src/ # React frontend (Vite)
|
||||
├── deploy/ # systemd service unit + install script
|
||||
├── scripts/ # backup / restore helpers
|
||||
└── setup.sh # Development quick-start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Private / personal use.
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run test:run # all unit and component tests
|
||||
npm run test:e2e # real-browser tests (Playwright, in your installed Chrome)
|
||||
npm run lint # ESLint (errors fail; warnings are advice)
|
||||
```
|
||||
|
||||
The browser tests (`e2e/`) build the app, run it on port 3101 with an empty throwaway database (your real studies are never
|
||||
touched), make an account, and click through the main flows: writing notes and the paste check, the daily five minutes and weekly
|
||||
recall, comparing translations (Bible text is faked, so no internet is needed), and presenting on two screens. They use the
|
||||
Chrome already on the computer, so there is no browser to download; install Chrome, or change `channel` in `playwright.config.js`
|
||||
and run `npx playwright install` to use Playwright's own browser.
|
||||
|
||||
Who can sign up: the administrator (the `ADMIN_EMAIL` account) chooses Open, Invite only or Closed under Admin → Sign-ups, and makes single-use invite links there. The very first account can always be created.
|
||||
|
||||
Verse of the day push: with `IQBIBLE_API_KEY` set, each person can turn on a daily verse notification in Settings → Reading reminders (it goes to the same ntfy topic).
|
||||
|
||||
Forgotten passwords: the sign-in page's "Forgot your password?" sends a one-time code to the account's ntfy topic (the one used for reading reminders). Accounts without a topic still ask the administrator for a temporary password.
|
||||
@@ -1,95 +0,0 @@
|
||||
# Study App Improvement Suggestions
|
||||
|
||||
## Features
|
||||
|
||||
### Study Tools
|
||||
- **Bible comparison mode** — show two translations side-by-side (API already supports it)
|
||||
- **Verse-level notes** — annotate individual verses, not just chunks
|
||||
- **Tagging / themes** — tag chunks with themes (e.g. "faith", "grace"), filter/search across projects
|
||||
- **Progress tracking** — mark chunks as "in progress" / "complete"; show progress bar on home card
|
||||
- **Study templates** — pre-fill OIA fields with guiding prompts for new users
|
||||
- **Print view** — clean print-optimized CSS layout
|
||||
- **Old Testament support** — only NT books listed; HelloAO API supports OT. Greek suggest is NT-only but the rest could support OT with Hebrew lookup
|
||||
- **Verse search** — search bar to find a verse by keyword across the loaded chapter
|
||||
|
||||
### Export / Sharing
|
||||
- **PDF export** — "Export PDF" button using `jsPDF` or `window.print()`
|
||||
- **Share link** — read-only shareable URL pointing to a project ID on the server
|
||||
- **Copy individual chunk** — "copy this chunk's notes" button alongside full "Prepare for Claude"
|
||||
- **Markdown export** — useful for Obsidian and similar note-taking apps
|
||||
|
||||
---
|
||||
|
||||
## UX / UI
|
||||
|
||||
### Navigation
|
||||
- **Keyboard shortcuts** — `←`/`→` to navigate chunks; `Ctrl+S` to save; `Escape` to close modals
|
||||
- **"Jump to chunk" dropdown** — for projects with many chunks, a select menu is faster than scrolling
|
||||
- **Breadcrumb in header** — show `Book Chapter:Verse range` so users always know where they are
|
||||
|
||||
### Chunk Builder (Setup Page)
|
||||
- **Drag-to-select verses** — click-and-drag instead of click then shift-click
|
||||
- **Auto-chunk** — button to split chapter into chunks by paragraph/section breaks
|
||||
- **Visual overlap indicator** — already-chunked verses are shaded but there's no tooltip explaining why you can't select them
|
||||
|
||||
### Study Page
|
||||
- **Collapsible sections** — collapse OIA, Cross-References, and Greek Word Studies independently
|
||||
- **Word/character count** on each textarea to encourage note depth
|
||||
- **Inline verse reference popup** — hover popover on cross-references showing verse text (from HelloAO)
|
||||
- **Sticky chunk navigation** — Previous/Next chunk buttons should be sticky, not only at the bottom
|
||||
|
||||
### Home Page
|
||||
- **Search/filter projects** — text filter on the project list
|
||||
- **Sort options** — sort by name, date, or passage
|
||||
- **Project rename** — title is only set at creation; allow renaming from home card
|
||||
- **Last opened chunk** — resume directly to the study page, not the setup page
|
||||
|
||||
---
|
||||
|
||||
## Code Architecture
|
||||
|
||||
### State Management
|
||||
- **`App.jsx` is ~2,250 lines** — biggest maintainability issue. Split into:
|
||||
- `pages/HomePage.jsx`
|
||||
- `pages/SetupPage.jsx`
|
||||
- `pages/StudyPage.jsx`
|
||||
- `components/ChunkEditor.jsx`
|
||||
- `components/GreekWordStudy.jsx`
|
||||
- `components/SuggestModal.jsx`
|
||||
- **Custom hooks** — extract logic into `useProject()`, `useGreekLookup()`, `useAutosave()`
|
||||
|
||||
### Sync / Persistence
|
||||
- **No auth** — the server has zero authentication. Any user who can reach the server can read/overwrite/delete any project. Add at minimum an API key (env var in middleware) or user accounts
|
||||
- **Conflict resolution is basic** — only compares `lastEdited` timestamps. Add a "which version do you want to keep?" UI to prevent silent data loss
|
||||
- **Offline-first** — use a service worker / `workbox` so the app works offline and syncs when back online
|
||||
|
||||
### Security (OWASP)
|
||||
- **XSS via `dangerouslySetInnerHTML`** — `word.definitionHtml` is rendered raw. Add DOMPurify sanitization
|
||||
- **No input validation on server** — add max-length and character validation on `id`/`title` fields (SQL injection is prevented by parameterized queries, but still)
|
||||
- **CORS** — server has no CORS headers; any origin can call the API in production
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
- **Verse data stored in project JSON** — full verse text is saved in localStorage and SQLite for every chapter. For multi-chapter projects this grows large. Consider storing only the chapter reference and re-fetching verses on load
|
||||
- **JSON files cached in refs** — `nt-strongs-gloss.json` and `nt-strongs-concordance.json` should be served with proper `Cache-Control` headers
|
||||
- **Autosave fires on all state changes** — the `[project]` dependency is too broad; it fires even when just selecting a chunk. Debounce only on content field changes
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- Add tests for:
|
||||
- `migrateProject` with the old flat format
|
||||
- `buildClaudePrompt` output structure
|
||||
- `parseBibleChapter` with both API response shapes
|
||||
- Autosave debounce behavior
|
||||
|
||||
---
|
||||
|
||||
## Developer Experience
|
||||
|
||||
- **No ESLint config** — add ESLint with `eslint-plugin-react` and `eslint-plugin-react-hooks` to catch missing `useEffect` deps
|
||||
- **No TypeScript** — JSDoc types or a TS migration would catch shape mismatches between old/new project formats at compile time
|
||||
- **No `docker-compose.yml`** — Dockerfile exists but there's no compose file for one-command local dev with server + SQLite volume
|
||||
@@ -0,0 +1,31 @@
|
||||
# To do
|
||||
|
||||
Ideas for the app that haven't been built yet, roughly in the order I'd do them. Tick one off when it ships.
|
||||
|
||||
## Running it and keeping it safe
|
||||
- [ ] **Email (SMTP).** A mail setting for password resets and invite links, so they don't depend on the ntfy topic. Also lets a
|
||||
co-teacher get their invite by email. Needs a `SMTP_URL`-style setting and a fallback to today's behaviour.
|
||||
- [x] **Passkeys and a device list.** Sign in with Face ID or a fingerprint (WebAuthn); show where you're signed in with
|
||||
"sign out other devices"; optionally a login alert through ntfy.
|
||||
- [x] **Server health page for the admin.** Last backup, disk use, failed notifications, recent sign-in failures, and a
|
||||
"restore a snapshot" button (snapshots already exist under `DATA_DIR/backups`).
|
||||
- [ ] **Checks on every push.** Run `npm run test:run`, `npm run lint` and `npm run test:e2e` on the Gitea server for every push.
|
||||
The real-browser tests now exist (`e2e/`, run by hand); what's left is running them automatically, and adding sign-in
|
||||
and cross-device sync to them.
|
||||
- [x] **Split `server/index.js`** into one file per area (`server/routes/`: auth, account, projects, reader, sharing, admin).
|
||||
|
||||
## Teaching together
|
||||
- [ ] **Comments between co-teachers.** Notes on a chunk addressed to the other person, instead of editing each other's text.
|
||||
- [ ] **Live updates and "who's here".** A co-teacher's changes appear within seconds, with a marker like "Sam is on chunk 3"
|
||||
(server-sent events or short polling). Today changes arrive when the tab regains focus or every couple of minutes.
|
||||
- [ ] **Ask Claude inside the app.** Draft discussion questions, tighten an outline point, summarise notes, using the owner's own
|
||||
API key kept on the server. The Claude prompts today are copy-and-paste.
|
||||
|
||||
## Reading
|
||||
- [ ] **Read-aloud with highlighting.** The device's voice reads the chapter and the verse being read is highlighted; useful for
|
||||
the BSB and NET, which have no audio.
|
||||
- [x] **Cross-reference map.** A picture of the links between chapters, from IQ Bible's reference graph (needs the IQ Bible key).
|
||||
|
||||
## Decided against, for now
|
||||
- Scripture memory (verse practice with spaced review).
|
||||
- Extra-biblical books (Enoch, Maccabees) and non-standard canons.
|
||||
@@ -19,26 +19,23 @@ SERVICE_NAME="study-app"
|
||||
|
||||
echo "=== Installing Bible Study App to $INSTALL_DIR ==="
|
||||
|
||||
# ── Node check ────────────────────────────────────────────────────────────────
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "ERROR: Node.js is not installed."
|
||||
echo "On Rocky Linux, install via NodeSource, e.g.:"
|
||||
echo " curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -"
|
||||
echo " sudo dnf install -y nodejs"
|
||||
exit 1
|
||||
# ── Node check / auto-upgrade ─────────────────────────────────────────────────
|
||||
NODE_VERSION=0
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
|
||||
fi
|
||||
NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
|
||||
if [ "$NODE_VERSION" -lt 18 ]; then
|
||||
echo "ERROR: Node.js v18+ required (found $(node -v))."
|
||||
exit 1
|
||||
echo "Node.js v18+ required (found v${NODE_VERSION}). Installing Node.js 20 via NodeSource..."
|
||||
apt-get install -y curl ca-certificates
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y nodejs
|
||||
fi
|
||||
echo "Node.js $(node -v) found."
|
||||
|
||||
# build tools needed for better-sqlite3 native module
|
||||
if ! command -v gcc >/dev/null 2>&1 || ! command -v make >/dev/null 2>&1; then
|
||||
echo "Installing build tools (Development Tools group + python3)..."
|
||||
dnf groupinstall -y "Development Tools"
|
||||
dnf install -y python3
|
||||
echo "Installing build tools (build-essential + python3)..."
|
||||
apt-get install -y build-essential python3
|
||||
fi
|
||||
|
||||
# ── Create service user ─────────────────────────────────────────────────────
|
||||
@@ -62,7 +59,7 @@ cd "$INSTALL_DIR"
|
||||
|
||||
# ── Install dependencies & build ─────────────────────────────────────────────
|
||||
echo "Installing dependencies (this can take a while for better-sqlite3)..."
|
||||
npm ci || npm install
|
||||
npm install
|
||||
|
||||
echo "Building production frontend..."
|
||||
npx vite build
|
||||
@@ -75,8 +72,16 @@ chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
|
||||
|
||||
# ── systemd unit ──────────────────────────────────────────────────────────────
|
||||
echo "Installing systemd unit..."
|
||||
sed "s#/opt/study-app#$INSTALL_DIR#g; s#User=study-app#User=$SERVICE_USER#; s#Group=study-app#Group=$SERVICE_USER#" \
|
||||
"$ROOT_DIR/deploy/study-app.service" > "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
EXISTING_UNIT="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
# Reuse the existing session secret across re-installs (upgrades) so signed-in
|
||||
# users aren't logged out; only generate a new one on first install.
|
||||
if [ -f "$EXISTING_UNIT" ] && grep -q '^Environment=SESSION_SECRET=' "$EXISTING_UNIT"; then
|
||||
SESSION_SECRET="$(grep '^Environment=SESSION_SECRET=' "$EXISTING_UNIT" | head -1 | cut -d= -f3-)"
|
||||
else
|
||||
SESSION_SECRET="$(openssl rand -hex 32)"
|
||||
fi
|
||||
sed "s#/opt/study-app#$INSTALL_DIR#g; s#User=study-app#User=$SERVICE_USER#; s#Group=study-app#Group=$SERVICE_USER#; s#__SESSION_SECRET__#$SESSION_SECRET#" \
|
||||
"$ROOT_DIR/deploy/study-app.service" > "$EXISTING_UNIT"
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE_NAME"
|
||||
@@ -89,4 +94,4 @@ echo "Logs: journalctl -u $SERVICE_NAME -f"
|
||||
echo "App listens on: http://0.0.0.0:\${PORT:-3001}"
|
||||
echo ""
|
||||
echo "If you have a firewall enabled, allow the port, e.g.:"
|
||||
echo " sudo firewall-cmd --add-port=3001/tcp --permanent && sudo firewall-cmd --reload"
|
||||
echo " sudo ufw allow 3001/tcp && sudo ufw reload"
|
||||
@@ -10,6 +10,9 @@ Group=study-app
|
||||
WorkingDirectory=/opt/study-app
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PORT=3001
|
||||
# Replaced with a generated value by install.sh — keep this secret and stable
|
||||
# across deploys, or every existing login session gets invalidated.
|
||||
Environment=SESSION_SECRET=__SESSION_SECRET__
|
||||
ExecStart=/usr/bin/node server/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Makes one account in the throwaway database and keeps its sign-in for the other tests.
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
|
||||
setup('create an account and sign in', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /need an account\? sign up/i }).click();
|
||||
await page.locator('input[type="email"]').fill('e2e@example.test');
|
||||
await page.locator('input[type="password"]').fill('E2e-Test-Password-7391');
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'My Studies' })).toBeVisible();
|
||||
await page.context().storageState({ path: 'e2e/.auth.json' });
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { resumeButton, seedStudy } from './helpers.js';
|
||||
|
||||
// Fake the free Bible text so the test needs no internet: every translation answers with its own wording.
|
||||
const WORDING = {
|
||||
BSB: { 16: 'For God so loved the world that He gave His one and only Son', 17: 'For God did not send His Son into the world to condemn the world', 18: 'Whoever believes in Him is not condemned' },
|
||||
NET: { 16: 'For this is the way God loved the world: He gave his one and only Son', 17: 'For God did not send his Son into the world to condemn the world', 18: 'The one who believes in him is not condemned' },
|
||||
};
|
||||
|
||||
test('compare translations with differences highlighted, a verse note, and the notes boxes', async ({ page }) => {
|
||||
await page.route('https://bible.helloao.org/api/**', async (route) => {
|
||||
const id = route.request().url().match(/api\/([^/]+)\//)[1].replace('eng_', '').toUpperCase();
|
||||
const words = WORDING[id] ?? WORDING.BSB;
|
||||
await route.fulfill({ json: { verses: Object.entries(words).map(([number, text]) => ({ number: Number(number), text })) } });
|
||||
});
|
||||
const { title } = await seedStudy(page);
|
||||
await resumeButton(page, title).click();
|
||||
await page.getByRole('button', { name: /compare…/i }).click();
|
||||
await page.getByRole('tab', { name: 'Translations' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: /compare/i });
|
||||
await expect(dialog.getByText('Berean Standard Bible')).toBeVisible();
|
||||
await expect(dialog.getByText('NET Bible', { exact: true })).toBeVisible();
|
||||
await expect(dialog.locator('mark').first()).toBeVisible(); // words that differ are marked
|
||||
await dialog.getByLabel(/highlight the words that differ/i).uncheck();
|
||||
await expect(dialog.locator('mark')).toHaveCount(0);
|
||||
|
||||
// a note on one verse, and the Observe / Interpret / Apply boxes edit the same notes as the Study page
|
||||
await dialog.getByRole('textbox', { name: 'Note on verse 16' }).fill('NET says "the way" where BSB says "so"');
|
||||
await expect(dialog.getByRole('textbox', { name: 'Interpret' })).toHaveValue(/not a slogan/);
|
||||
await dialog.getByRole('textbox', { name: 'Apply' }).fill('Decide one thing to trust him with.');
|
||||
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||
await page.getByRole('button', { name: 'Your notes' }).first().click(); // keep both halves tidy
|
||||
await page.keyboard.press('3');
|
||||
await expect(page.getByRole('textbox', { name: 'Apply' })).toHaveValue('Decide one thing to trust him with.');
|
||||
await expect(page.getByRole('textbox', { name: 'Verse 16 note' })).toHaveValue(/the way/);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// Shared test helpers: a ready-made study so tests don't depend on downloading Bible text.
|
||||
const DAY = 86400000;
|
||||
const verses = [
|
||||
{ number: 16, text: 'For God so loved the world, that he gave his only begotten Son, that whosoever believeth in him should not perish, but have everlasting life.' },
|
||||
{ number: 17, text: 'For God sent not his Son into the world to condemn the world; but that the world through him might be saved.' },
|
||||
{ number: 18, text: 'He that believeth on him is not condemned: but he that believeth not is condemned already.' },
|
||||
{ number: 19, text: 'And this is the condemnation, that light is come into the world, and men loved darkness rather than light, because their deeds were evil.' },
|
||||
];
|
||||
export const NOTE_TEXT = {
|
||||
observation: 'Jesus is speaking to Nicodemus at night. The verse starts with "for", so it explains the previous verses about the serpent lifted up. God is the one who acts: he loved, he gave.',
|
||||
interpretation: 'In its setting this is not a slogan but the reason the cross happens. The world is the object of love, not just the good in it. Giving the Son points back to Moses lifting the serpent.',
|
||||
application: 'I do not have to earn being loved before I trust him. This week I want to stop treating belief as a feeling and decide one concrete thing to trust him with.',
|
||||
};
|
||||
export const SPEAKER_NOTE = 'PRIVATE: open with the story about Nicodemus at night, then pause after perish.';
|
||||
|
||||
// Writes a two-chunk John 3 study (edited two weeks ago, so a weekly recall is due) into this browser's storage
|
||||
// for the signed-in account, then reloads.
|
||||
export async function seedStudy(page, { id = `e2e-${Date.now()}-${Math.floor(Math.random() * 1e6)}`, withOutline = false } = {}) {
|
||||
await page.goto('/');
|
||||
await page.waitForFunction(() => Object.keys(localStorage).some((k) => /^bible-study-index:.+:known$/.test(k)));
|
||||
await page.evaluate(({ id, verses, notes, speakerNote, withOutline, DAY }) => {
|
||||
const uid = Object.keys(localStorage).find((k) => /^bible-study-index:.+:known$/.test(k)).split(':')[1];
|
||||
const now = Date.now();
|
||||
const chunk = (cid, s, e, extra) => ({ id: cid, startVerse: s, endVerse: e, spilloverEndVerse: null, observation: '', interpretation: '', application: '', crossReferences: [], greekWords: [], generalNotes: '', episodeNumber: '', episodeTitle: '', finalScript: '', tags: [], editedAt: now - 14 * DAY, ...extra });
|
||||
const node = (nid, text, extra = {}) => ({ id: nid, text, ref: '', chunkId: null, note: '', kind: '', minutes: null, scripture: null, attachments: [], children: [], ...extra });
|
||||
const a = chunk(`${id}-a`, 16, 18, { ...notes });
|
||||
const b = chunk(`${id}-b`, 19, 19, {});
|
||||
// a title of its own: studies from earlier tests sync back through the account, so titles must not repeat
|
||||
const title = `John 3 (${id.slice(-6)})`;
|
||||
const outline = withOutline ? [node('n1', 'God so loved', { minutes: 5, note: speakerNote, ref: 'John 3:16', scripture: { text: verses[0].text, ref: 'John 3:16' } }), node('n2', 'Light and darkness', { minutes: 5 })] : [];
|
||||
const project = { id, title, translation: 'BSB', lastEdited: now - 2 * DAY, selectedChunkId: a.id, outline, chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses, chunks: [a, b] }] };
|
||||
localStorage.setItem(`bible-study-project:${uid}:${id}`, JSON.stringify(project));
|
||||
const index = JSON.parse(localStorage.getItem(`bible-study-index:${uid}`) || '[]').filter((e) => e.id !== id);
|
||||
index.push({ id, title: project.title, lastEdited: project.lastEdited, chapterSummary: 'John 3', series: '', tags: [] });
|
||||
localStorage.setItem(`bible-study-index:${uid}`, JSON.stringify(index));
|
||||
}, { id, verses, notes: NOTE_TEXT, speakerNote: SPEAKER_NOTE, withOutline, DAY });
|
||||
await page.reload();
|
||||
return { id, title: `John 3 (${id.slice(-6)})` };
|
||||
}
|
||||
|
||||
// The Resume button on one study's card on Home
|
||||
export const resumeButton = (page, title) => page.getByRole('heading', { name: title }).locator('xpath=ancestor::div[contains(@class,"rounded-2xl")][1]').getByRole('button', { name: 'Resume' });
|
||||
|
||||
// Fakes a long paste into a note box (Playwright can't use the real clipboard in every setup)
|
||||
export async function pasteInto(locator, text) {
|
||||
await locator.evaluate((el, value) => {
|
||||
const dt = new DataTransfer();
|
||||
dt.setData('text', value);
|
||||
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
|
||||
}, text);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { pasteInto, seedStudy } from './helpers.js';
|
||||
|
||||
test('the daily five minutes blocks pasting and saves to the note; the weekly recall rates and records', async ({ page }) => {
|
||||
await seedStudy(page);
|
||||
|
||||
// daily five minutes
|
||||
await expect(page.getByText(/your five minutes:/i)).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Start' }).first().click();
|
||||
const box = page.getByRole('textbox', { name: /your five minutes/i });
|
||||
await expect(page.getByRole('timer')).toContainText('4:');
|
||||
await pasteInto(box, 'text from somewhere else');
|
||||
await expect(page.getByText(/pasting is off here/i)).toBeVisible();
|
||||
await expect(box).toHaveValue('');
|
||||
await box.fill('Three things I notice in the passage.');
|
||||
await page.getByRole('button', { name: /save to my notes/i }).click();
|
||||
await expect(page.getByText(/saved to your notes/i)).toBeVisible();
|
||||
|
||||
// weekly recall: write from memory, compare with the notes, rate it
|
||||
await page.getByRole('button', { name: 'Start' }).click();
|
||||
await page.getByRole('textbox', { name: 'What you remember' }).fill('God loved the world and gave his Son so that believers would not perish.');
|
||||
await page.getByRole('button', { name: 'Show my notes' }).click();
|
||||
await expect(page.getByText('Your notes', { exact: true })).toBeVisible();
|
||||
await page.getByRole('textbox', { name: 'What slipped' }).fill('who Jesus was talking to');
|
||||
await page.getByRole('button', { name: 'Partly' }).click();
|
||||
await expect(page.getByText(/recall saved/i)).toBeVisible();
|
||||
|
||||
// it shows in the history and in Insights
|
||||
await page.getByRole('button', { name: /recall history/i }).click();
|
||||
await expect(page.getByText('Partly', { exact: true }).first()).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Insights' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Insights' })).toBeVisible();
|
||||
await expect(page.getByText('who Jesus was talking to')).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { SPEAKER_NOTE, resumeButton, seedStudy } from './helpers.js';
|
||||
|
||||
test('presenting on two screens: the audience window never shows the speaker notes', async ({ page, context }) => {
|
||||
const { title } = await seedStudy(page, { withOutline: true });
|
||||
await resumeButton(page, title).click();
|
||||
await page.getByRole('navigation', { name: 'Study stages' }).getByRole('button', { name: 'Outline' }).click();
|
||||
await page.getByRole('button', { name: 'Present' }).click();
|
||||
const presenter = page.getByRole('dialog', { name: /presenting/i });
|
||||
|
||||
// one screen: notes stay hidden until turned on
|
||||
await expect(presenter.getByRole('heading', { name: /God so loved/ })).toBeVisible();
|
||||
await expect(presenter.getByText(SPEAKER_NOTE)).toHaveCount(0);
|
||||
|
||||
// a second window (the projector) connects and follows along
|
||||
const audience = await context.newPage();
|
||||
await audience.goto('/?audience=1');
|
||||
await expect(audience.getByRole('heading', { name: /God so loved/ })).toBeVisible();
|
||||
await expect(presenter.getByText('Only you see this')).toBeVisible();
|
||||
await expect(presenter.getByText(SPEAKER_NOTE)).toBeVisible(); // on the presenter's screen only
|
||||
await expect(audience.getByText(SPEAKER_NOTE)).toHaveCount(0);
|
||||
expect(await audience.locator('body').innerText()).not.toMatch(/PRIVATE|On pace|Start/);
|
||||
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(audience.getByRole('heading', { name: /Light and darkness/ })).toBeVisible();
|
||||
|
||||
// B blacks the audience screen out, and again brings it back
|
||||
await page.keyboard.press('b');
|
||||
await expect(audience.getByRole('heading', { name: /Light and darkness/ })).toHaveCount(0);
|
||||
await page.keyboard.press('b');
|
||||
await expect(audience.getByRole('heading', { name: /Light and darkness/ })).toBeVisible();
|
||||
|
||||
// closing the presentation tells the audience window
|
||||
await presenter.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(audience.getByText(/presentation has ended/i)).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the app and runs the server on port 3101 with a fresh, empty data folder (so tests never touch real studies).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
export DATA_DIR="$(mktemp -d)"
|
||||
export PORT=3101
|
||||
export NODE_ENV=production
|
||||
npx vite build --logLevel error
|
||||
exec node server/index.js
|
||||
@@ -0,0 +1,43 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { NOTE_TEXT, pasteInto, resumeButton, seedStudy } from './helpers.js';
|
||||
|
||||
test('open a study, write notes, answer the paste check, and use the keyboard', async ({ page }) => {
|
||||
const { title } = await seedStudy(page);
|
||||
await expect(page.getByRole('heading', { name: title })).toBeVisible();
|
||||
await expect(page.getByText(/needs a connection for john/i).first()).toBeVisible(); // the offline badge
|
||||
await resumeButton(page, title).click();
|
||||
await expect(page.getByRole('heading', { name: 'John 3:16–18' })).toBeVisible();
|
||||
|
||||
// keyboard: 2 jumps into Interpret, Escape leaves it
|
||||
await page.keyboard.press('2');
|
||||
await expect(page.getByRole('textbox', { name: 'Interpret' })).toBeFocused();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('textbox', { name: 'Interpret' })).not.toBeFocused();
|
||||
|
||||
// a long paste asks whether it is from AI; "No, I wrote it" counts the words as yours
|
||||
await page.keyboard.press('1');
|
||||
const observe = page.getByRole('textbox', { name: 'Observe' });
|
||||
const pasted = Array.from({ length: 24 }, (_, i) => `pasted${i}`).join(' ');
|
||||
await pasteInto(observe, pasted);
|
||||
await observe.fill(`${NOTE_TEXT.observation} ${pasted}`);
|
||||
await expect(page.getByText(/is this from ai\?/i)).toBeVisible();
|
||||
await expect(page.getByText(/24 words here were pasted/i)).toBeVisible();
|
||||
await page.getByRole('button', { name: 'No, I wrote it' }).click();
|
||||
await expect(page.getByText(/is this from ai\?/i)).toHaveCount(0);
|
||||
await expect(page.getByText(/were pasted/i)).toHaveCount(0);
|
||||
|
||||
// either half of the right column can be folded
|
||||
const resources = page.getByRole('button', { name: /commentary & resources/i });
|
||||
await resources.click();
|
||||
await expect(resources).toHaveAttribute('aria-expanded', 'false');
|
||||
await page.getByRole('button', { name: 'Your notes' }).click(); // folding the last open half opens the other
|
||||
await expect(resources).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// the reading list belongs to the study
|
||||
await page.getByRole('button', { name: 'Your notes' }).click(); // make room: fold notes, resources stay open
|
||||
await page.getByRole('tab', { name: /reading list/i }).click();
|
||||
await page.getByRole('textbox', { name: /add to the reading list/i }).fill('Carson on John');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('checkbox', { name: /read: carson on john/i }).check();
|
||||
await expect(page.getByRole('checkbox', { name: /read: carson on john/i })).toBeChecked();
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist/**', 'coverage/**', 'node_modules/**', 'server/data/**', 'public/**'] },
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['src/**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: { ...globals.browser },
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
plugins: { 'react-hooks': reactHooks },
|
||||
rules: {
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'no-unused-vars': ['warn', { varsIgnorePattern: '^_|^[A-Z]', argsIgnorePattern: '^_', caughtErrors: 'none' }],
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['server/**/*.js', 'scripts/**/*.mjs', 'vite.config.js', 'playwright.config.js', 'eslint.config.js', 'postcss.config.js', 'tailwind.config.js'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node } },
|
||||
rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }], 'no-empty': ['error', { allowEmptyCatch: true }] },
|
||||
},
|
||||
{
|
||||
// Playwright tests run in Node but hand functions to the browser page, which use browser globals
|
||||
files: ['e2e/**/*.js'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node, ...globals.browser } },
|
||||
rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }] },
|
||||
},
|
||||
{
|
||||
files: ['**/*.test.{js,jsx}', 'src/setupTests.js', 'src/test/**'],
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.node, vi: 'readonly' } },
|
||||
},
|
||||
];
|
||||
@@ -4,8 +4,23 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Bible Study Project</title>
|
||||
|
||||
<!-- PWA manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Theme / status bar -->
|
||||
<meta name="theme-color" content="#f1ece0" />
|
||||
|
||||
<!-- iOS PWA -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
<meta name="apple-mobile-web-app-title" content="Bible Study" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- Fallback icon for browsers -->
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
||||
</head>
|
||||
<body class="bg-slate-50 text-slate-900">
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -12,19 +12,33 @@
|
||||
"start": "NODE_ENV=production node server/index.js",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"coverage": "vitest run --coverage"
|
||||
"coverage": "vitest run --coverage",
|
||||
"backup": "bash scripts/backup-data.sh",
|
||||
"restore": "bash scripts/restore-data.sh",
|
||||
"lint": "eslint .",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.3",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"compression": "^1.8.2",
|
||||
"concurrently": "^8.2.2",
|
||||
"docx": "^9.7.0",
|
||||
"dompurify": "^3.4.9",
|
||||
"express": "^4.19.2",
|
||||
"express-session": "^1.19.0",
|
||||
"mammoth": "^1.12.0",
|
||||
"otplib": "^12.0.1",
|
||||
"perfect-freehand": "^1.2.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -32,6 +46,10 @@
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"csv-parse": "^6.2.1",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.13.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.4",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Real-browser tests: a production build of the app on its own port with a throwaway database, driven in Chrome.
|
||||
// Run with `npm run test:e2e`. It uses the Chrome already on this computer (no browser download).
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: [['list']],
|
||||
use: { baseURL: 'http://localhost:3101', channel: 'chrome', trace: 'retain-on-failure' },
|
||||
webServer: { command: 'bash e2e/start-server.sh', url: 'http://localhost:3101/', timeout: 120_000, reuseExistingServer: false },
|
||||
projects: [
|
||||
{ name: 'setup', testMatch: /auth\.setup\.js/ },
|
||||
{ name: 'chrome', testMatch: /.*\.spec\.js/, dependencies: ['setup'], use: { storageState: 'e2e/.auth.json' } },
|
||||
],
|
||||
});
|
||||
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 420 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Bible Study Project">
|
||||
<defs>
|
||||
<radialGradient id="glow" cx="50%" cy="34%" r="45%">
|
||||
<stop offset="0" stop-color="#fbbf24" stop-opacity=".35"/>
|
||||
<stop offset="1" stop-color="#fbbf24" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="#0f172a"/>
|
||||
<rect width="512" height="512" rx="112" fill="url(#glow)"/>
|
||||
<!-- cross -->
|
||||
<rect x="238" y="82" width="36" height="170" rx="6" fill="#fbbf24"/>
|
||||
<rect x="200" y="122" width="112" height="34" rx="6" fill="#fbbf24"/>
|
||||
<!-- open book -->
|
||||
<path d="M256 392 C206 356 146 350 80 366 L80 262 C146 246 206 252 256 286 Z" fill="#f8fafc"/>
|
||||
<path d="M256 392 C306 356 366 350 432 366 L432 262 C366 246 306 252 256 286 Z" fill="#e2e8f0"/>
|
||||
<path d="M60 384 C136 366 206 374 256 414 C306 374 376 366 452 384" fill="none" stroke="#fbbf24" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M256 286 V392" stroke="#0f172a" stroke-width="6" opacity=".35"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "Bible Study Project",
|
||||
"short_name": "Bible Study",
|
||||
"description": "Your personal verse-by-verse Bible study companion",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#faf7f0",
|
||||
"theme_color": "#f1ece0",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
Drop a cover photo here for each built-in reading plan, named exactly:
|
||||
|
||||
bible-year.jpg Whole Bible in a Year
|
||||
bible-90.jpg Whole Bible in 90 Days
|
||||
nt-90.jpg New Testament in 90 Days
|
||||
nt-30.jpg New Testament in 30 Days
|
||||
gospels-30.jpg The Gospels in 30 Days
|
||||
psalms-30.jpg Psalms in 30 Days
|
||||
proverbs-31.jpg Proverbs in 31 Days
|
||||
pentateuch-60.jpg The Pentateuch in 60 Days
|
||||
wisdom-40.jpg Wisdom Literature in 40 Days
|
||||
|
||||
Landscape orientation, ~1200px wide, works best (cards crop to a 16:9 box).
|
||||
Any plan without a matching file just shows its colorful gradient + icon —
|
||||
nothing breaks if some are missing.
|
||||
|
||||
See src/utils/readingPlans.js -> PIXABAY_SEARCH_LINKS for a Pixabay search
|
||||
link per plan to help pick one.
|
||||
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 202 KiB |
@@ -0,0 +1,97 @@
|
||||
const SHELL = 'bible-shell-v2';
|
||||
const API = 'bible-api-v1';
|
||||
// Chapter text, interlinear cross-refs, and (BSB) chapter audio all come from
|
||||
// these origins — cache them so the reader, reading plans, and audio all keep
|
||||
// working offline once you've visited a passage before.
|
||||
const BIBLE_ORIGINS = ['https://bible.helloao.org', 'https://bolls.life', 'https://audio.bible.helloao.org'];
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(
|
||||
caches.open(SHELL)
|
||||
.then(c => c.addAll(['/', '/index.html']))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys()
|
||||
.then(keys => Promise.all(
|
||||
keys.filter(k => k !== SHELL && k !== API).map(k => caches.delete(k))
|
||||
))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const { request } = e;
|
||||
const url = request.url;
|
||||
|
||||
// Never intercept this app's own API routes — let Express handle them. (Only same-origin ones: the
|
||||
// Bible text at bible.helloao.org/api/... must pass through to the offline cache below.)
|
||||
const { origin, pathname } = new URL(url);
|
||||
if (origin === self.location.origin && pathname.startsWith('/api/')) return;
|
||||
|
||||
// External Bible API (text, cross-refs, audio): network-first, fall back to cache.
|
||||
// Audio elements often issue Range requests, which come back as 206 Partial
|
||||
// Content — the Cache API throws on those, so only cache full (200) responses.
|
||||
if (BIBLE_ORIGINS.some(o => url.startsWith(o))) {
|
||||
e.respondWith(
|
||||
fetch(request)
|
||||
.then(res => {
|
||||
// Clone *now*: once the page starts reading the body a later clone() throws and nothing is saved.
|
||||
if (res.status === 200) {
|
||||
const copy = res.clone();
|
||||
e.waitUntil(caches.open(API).then(c => c.put(request, copy)).catch(() => {}));
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch(() =>
|
||||
caches.open(API).then(c => c.match(request)).then(
|
||||
hit => hit ?? new Response('{"error":"offline"}', {
|
||||
status: 503, headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// App-shell navigation: network-first, fall back to cached index.html
|
||||
if (request.mode === 'navigate') {
|
||||
e.respondWith(
|
||||
fetch(request)
|
||||
.then(res => {
|
||||
if (res.ok) {
|
||||
const copy = res.clone();
|
||||
e.waitUntil(caches.open(SHELL).then(c => c.put(request, copy)).catch(() => {}));
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch(() =>
|
||||
caches.open(SHELL).then(c =>
|
||||
c.match('/index.html').then(hit => hit ?? c.match('/'))
|
||||
)
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Same-origin static assets (hashed JS/CSS/images): cache-first
|
||||
if (url.startsWith(self.location.origin)) {
|
||||
e.respondWith(
|
||||
caches.open(SHELL).then(c =>
|
||||
c.match(request).then(hit => {
|
||||
if (hit) return hit;
|
||||
return fetch(request).then(res => {
|
||||
if (res.ok) {
|
||||
const copy = res.clone();
|
||||
e.waitUntil(c.put(request, copy).catch(() => {}));
|
||||
}
|
||||
return res;
|
||||
});
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
Photos for behind the verse of the day and the "Share as image" pictures.
|
||||
|
||||
1. Drop landscape photos (about 1200px wide or larger, .jpg/.png/.webp) into this folder.
|
||||
2. List them in index.json, for example:
|
||||
|
||||
[
|
||||
{ "file": "mountain-lake.jpg", "name": "Mountain lake" },
|
||||
{ "file": "wheat.jpg", "name": "Wheat" }
|
||||
]
|
||||
|
||||
(A plain list of file names also works: ["mountain-lake.jpg", "wheat.jpg"].)
|
||||
|
||||
They then appear under "Your photos" in the background menus, and "Picture of the day" rotates through them
|
||||
along with the built-in scenes. With no index.json the built-in scenes are used. Free sources: pixabay.com,
|
||||
unsplash.com. Check each photo's licence before sharing pictures made from it.
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -3,23 +3,12 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
stop_pid_file() {
|
||||
local pid_file="$1"
|
||||
local label="$2"
|
||||
if [ -f "$pid_file" ]; then
|
||||
local old_pid
|
||||
old_pid=$(cat "$pid_file")
|
||||
if kill -0 "$old_pid" 2>/dev/null; then
|
||||
echo "Stopping $label (PID $old_pid)..."
|
||||
kill "$old_pid"
|
||||
sleep 1
|
||||
fi
|
||||
rm -f "$pid_file"
|
||||
fi
|
||||
}
|
||||
echo "Pulling latest changes from Gitea..."
|
||||
cd "$ROOT_DIR"
|
||||
git checkout -- package-lock.json 2>/dev/null || true
|
||||
git pull
|
||||
|
||||
stop_pid_file "$ROOT_DIR/.api.pid" "API server"
|
||||
stop_pid_file "$ROOT_DIR/.vite.pid" "Vite server"
|
||||
|
||||
echo "Restarting via setup.sh..."
|
||||
exec "$ROOT_DIR/setup.sh"
|
||||
# setup.sh builds first and only then stops the old server, so the site
|
||||
# stays up during the build (and if the build fails). Pass --dev through.
|
||||
echo "Rebuilding and restarting via setup.sh..."
|
||||
exec "$ROOT_DIR/setup.sh" "$@"
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
# Backs up user data to a timestamped zip file you can copy to your new PC.
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
DB_FILE="$APP_DIR/server/data/projects.db"
|
||||
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||
BACKUP_FILE="$APP_DIR/study-app-backup_$TIMESTAMP.zip"
|
||||
|
||||
if [ ! -f "$DB_FILE" ]; then
|
||||
echo "ERROR: Database not found at $DB_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
zip -j "$BACKUP_FILE" "$DB_FILE"
|
||||
|
||||
echo ""
|
||||
echo "Backup created: $BACKUP_FILE"
|
||||
echo ""
|
||||
echo "Copy this file to your new PC, then run:"
|
||||
echo " scripts/restore-data.sh <path-to-backup-file>"
|
||||
@@ -0,0 +1,115 @@
|
||||
// Generates PWA icon PNGs using only Node.js built-ins (no dependencies).
|
||||
import { deflateSync } from 'zlib';
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
|
||||
function crc32(buf) {
|
||||
const table = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
table[i] = c;
|
||||
}
|
||||
let crc = 0xffffffff;
|
||||
for (const b of buf) crc = table[(crc ^ b) & 0xff] ^ (crc >>> 8);
|
||||
return ((crc ^ 0xffffffff) >>> 0);
|
||||
}
|
||||
|
||||
function u32(n) {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32BE(n, 0);
|
||||
return b;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const t = Buffer.from(type, 'ascii');
|
||||
return Buffer.concat([u32(data.length), t, data, u32(crc32(Buffer.concat([t, data])))]);
|
||||
}
|
||||
|
||||
function makePNG(size, draw) {
|
||||
const px = new Uint8ClampedArray(size * size * 4); // RGBA
|
||||
draw(px, size);
|
||||
|
||||
const rows = [];
|
||||
for (let y = 0; y < size; y++) {
|
||||
rows.push(0); // PNG filter byte: None
|
||||
for (let x = 0; x < size; x++) {
|
||||
const i = (y * size + x) * 4;
|
||||
rows.push(px[i], px[i + 1], px[i + 2], px[i + 3]);
|
||||
}
|
||||
}
|
||||
|
||||
const raw = Buffer.from(rows);
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ihdr = chunk('IHDR', Buffer.concat([u32(size), u32(size), Buffer.from([8, 6, 0, 0, 0])]));
|
||||
const idat = chunk('IDAT', deflateSync(raw, { level: 9 }));
|
||||
const iend = chunk('IEND', Buffer.alloc(0));
|
||||
return Buffer.concat([sig, ihdr, idat, iend]);
|
||||
}
|
||||
|
||||
function setPixel(px, size, x, y, r, g, b, a = 255) {
|
||||
if (x < 0 || x >= size || y < 0 || y >= size) return;
|
||||
const i = (y * size + x) * 4;
|
||||
px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = a;
|
||||
}
|
||||
|
||||
function fillRect(px, size, x1, y1, x2, y2, r, g, b, a = 255) {
|
||||
for (let y = Math.max(0, y1); y < Math.min(size, y2); y++)
|
||||
for (let x = Math.max(0, x1); x < Math.min(size, x2); x++)
|
||||
setPixel(px, size, x, y, r, g, b, a);
|
||||
}
|
||||
|
||||
function drawIcon(px, S) {
|
||||
const rad = Math.round(S * 0.22); // corner radius
|
||||
|
||||
// Rounded background: #0f172a (15, 23, 42)
|
||||
for (let y = 0; y < S; y++) {
|
||||
for (let x = 0; x < S; x++) {
|
||||
const cx = Math.min(x, S - 1 - x);
|
||||
const cy = Math.min(y, S - 1 - y);
|
||||
if (cx < rad && cy < rad) {
|
||||
const dx = rad - cx - 1;
|
||||
const dy = rad - cy - 1;
|
||||
if (dx * dx + dy * dy > rad * rad) { setPixel(px, S, x, y, 0, 0, 0, 0); continue; }
|
||||
}
|
||||
setPixel(px, S, x, y, 15, 23, 42);
|
||||
}
|
||||
}
|
||||
|
||||
// Cross: vertical bar (center-ish, top-of-cross higher than center)
|
||||
const cw = Math.round(S * 0.12); // cross bar thickness
|
||||
const cx = Math.round(S / 2 - cw / 2);
|
||||
const vTop = Math.round(S * 0.18);
|
||||
const vBot = Math.round(S * 0.82);
|
||||
fillRect(px, S, cx, vTop, cx + cw, vBot, 255, 255, 255);
|
||||
|
||||
// Horizontal bar (slightly above center)
|
||||
const hh = Math.round(S * 0.12);
|
||||
const hy = Math.round(S * 0.36 - hh / 2);
|
||||
const hLeft = Math.round(S * 0.22);
|
||||
const hRight = Math.round(S * 0.78);
|
||||
fillRect(px, S, hLeft, hy, hRight, hy + hh, 255, 255, 255);
|
||||
|
||||
// Subtle glow/shine at cross intersection (slightly lighter center)
|
||||
const glowR = Math.round(S * 0.07);
|
||||
const gcx = Math.round(S / 2);
|
||||
const gcy = Math.round(S * 0.36);
|
||||
for (let y = gcy - glowR; y <= gcy + glowR; y++) {
|
||||
for (let x = gcx - glowR; x <= gcx + glowR; x++) {
|
||||
const d2 = (x - gcx) ** 2 + (y - gcy) ** 2;
|
||||
if (d2 <= glowR * glowR) {
|
||||
const i = (Math.max(0, Math.min(S - 1, y)) * S + Math.max(0, Math.min(S - 1, x))) * 4;
|
||||
if (px[i + 3] === 255) {
|
||||
px[i] = Math.min(255, px[i] + 20);
|
||||
px[i + 1] = Math.min(255, px[i + 1] + 20);
|
||||
px[i + 2] = Math.min(255, px[i + 2] + 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mkdirSync('public', { recursive: true });
|
||||
writeFileSync('public/icon-192.png', makePNG(192, drawIcon));
|
||||
writeFileSync('public/icon-512.png', makePNG(512, drawIcon));
|
||||
writeFileSync('public/apple-touch-icon.png', makePNG(180, drawIcon));
|
||||
console.log('Icons written to public/');
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
# Restores user data from a backup zip created by backup-data.sh.
|
||||
#
|
||||
# Development usage:
|
||||
# npm run restore <path-to-backup-file>
|
||||
#
|
||||
# Production usage (restores into the running service directory):
|
||||
# sudo bash scripts/restore-data.sh <path-to-backup-file> --production
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
BACKUP_FILE="$1"
|
||||
MODE="${2:-}"
|
||||
|
||||
if [ -z "$BACKUP_FILE" ]; then
|
||||
echo "Usage: scripts/restore-data.sh <path-to-backup-file> [--production]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$BACKUP_FILE" ]; then
|
||||
echo "ERROR: Backup file not found: $BACKUP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "--production" ]; then
|
||||
DATA_DIR="/opt/study-app/server/data"
|
||||
SERVICE_USER="study-app"
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "ERROR: Production restore must be run as root (sudo)."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
DATA_DIR="$APP_DIR/server/data"
|
||||
SERVICE_USER=""
|
||||
fi
|
||||
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
# Warn if a database already exists
|
||||
if [ -f "$DATA_DIR/projects.db" ]; then
|
||||
echo "WARNING: An existing database was found at $DATA_DIR/projects.db"
|
||||
read -p "Overwrite it? (y/N): " CONFIRM
|
||||
if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then
|
||||
echo "Restore cancelled."
|
||||
exit 0
|
||||
fi
|
||||
cp "$DATA_DIR/projects.db" "$DATA_DIR/projects.db.bak"
|
||||
echo "Old database backed up to projects.db.bak"
|
||||
fi
|
||||
|
||||
unzip -o "$BACKUP_FILE" -d "$DATA_DIR"
|
||||
|
||||
if [ -n "$SERVICE_USER" ]; then
|
||||
chown "$SERVICE_USER":"$SERVICE_USER" "$DATA_DIR/projects.db"
|
||||
echo "Restarting study-app service..."
|
||||
systemctl restart study-app
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Restore complete. Database is at: $DATA_DIR/projects.db"
|
||||
if [ "$MODE" = "--production" ]; then
|
||||
echo "Check service status: systemctl status study-app"
|
||||
else
|
||||
echo "You can now start the app normally."
|
||||
fi
|
||||
@@ -0,0 +1,35 @@
|
||||
// Everything one account holds on the server, in the same file format the app's own "Download backup"
|
||||
// makes, so it can be restored from Settings on any device.
|
||||
export const BACKUP_APP = 'bible-study-project';
|
||||
export const BACKUP_VERSION = 1;
|
||||
|
||||
// `store` supplies the account's data: { projects(), prefs(), marks(), ink(), plans() }.
|
||||
export function buildAccountExport(user, store, now = new Date()) {
|
||||
const prefs = store.prefs() ?? {};
|
||||
const marks = store.marks() ?? {};
|
||||
const plans = store.plans() ?? {};
|
||||
return {
|
||||
app: BACKUP_APP,
|
||||
version: BACKUP_VERSION,
|
||||
exportedAt: now.toISOString(),
|
||||
account: user.email,
|
||||
source: 'server',
|
||||
projects: store.projects(),
|
||||
reader: {
|
||||
bookmarks: marks.bookmarks ?? {},
|
||||
bookmarkNotes: marks.bookmarkNotes ?? {},
|
||||
highlights: marks.highlights ?? [],
|
||||
colorLabels: prefs.colorLabels?.v ?? {},
|
||||
ink: store.ink() ?? {},
|
||||
},
|
||||
readingHistory: store.history?.() ?? { chapters: {}, days: [] },
|
||||
prayers: store.prayers?.() ?? [],
|
||||
readingPlans: {
|
||||
progress: plans.progress ?? [],
|
||||
customPlans: plans.customPlans ?? [],
|
||||
activity: plans.activity ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const accountExportName = (now = new Date()) => `bible-study-account-${now.toISOString().slice(0, 10)}.json`;
|
||||
@@ -0,0 +1,33 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect } from 'vitest';
|
||||
import { buildAccountExport, accountExportName, BACKUP_APP } from './accountExport.js';
|
||||
import { listSnapshots, pruneSnapshots } from './backups.js';
|
||||
import { mkdtempSync, writeFileSync, readdirSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
test('the account export has the same shape as the in-app backup', () => {
|
||||
const out = buildAccountExport({ email: 'a@b.co' }, {
|
||||
projects: () => [{ id: 'p', title: 'T', chapters: [] }],
|
||||
prefs: () => ({ colorLabels: { v: { '#fef08a': 'Promises' }, at: 1 } }),
|
||||
marks: () => ({ bookmarks: { 'JHN-3-16': '#fff' }, highlights: [{ id: 'h' }] }),
|
||||
ink: () => ({ JHN_3: [[1, 2]] }),
|
||||
plans: () => ({ progress: [{ id: 'x' }] }),
|
||||
}, new Date('2026-10-06T10:00:00Z'));
|
||||
expect(out.app).toBe(BACKUP_APP);
|
||||
expect(out.account).toBe('a@b.co');
|
||||
expect(out.reader.colorLabels).toEqual({ '#fef08a': 'Promises' });
|
||||
expect(out.reader.bookmarks['JHN-3-16']).toBe('#fff');
|
||||
expect(out.readingPlans).toEqual({ progress: [{ id: 'x' }], customPlans: [], activity: [] });
|
||||
expect(accountExportName(new Date('2026-10-06T10:00:00Z'))).toBe('bible-study-account-2026-10-06.json');
|
||||
});
|
||||
|
||||
test('only the newest snapshots are kept, and always at least one', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'snap-'));
|
||||
['2026-10-01-03-00', '2026-10-02-03-00', '2026-10-03-03-00', '2026-10-04-03-00'].forEach((d) => writeFileSync(join(dir, `projects-${d}.db`), 'x'));
|
||||
writeFileSync(join(dir, 'notes.txt'), 'not a snapshot');
|
||||
expect(listSnapshots(dir).map((s) => s.name)[0]).toBe('projects-2026-10-04-03-00.db');
|
||||
expect(pruneSnapshots(2, dir)).toEqual(['projects-2026-10-02-03-00.db', 'projects-2026-10-01-03-00.db']);
|
||||
expect(pruneSnapshots(0, dir)).toHaveLength(1); // keep at least the newest
|
||||
expect(readdirSync(dir).sort()).toEqual(['notes.txt', 'projects-2026-10-04-03-00.db']);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { authenticator } from 'otplib';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { getUserById } from './db.js';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
// The single admin account for this deployment. Override via env var if you
|
||||
// redeploy this app for someone else — don't hardcode your own email into a
|
||||
// fork without changing this.
|
||||
const ADMIN_EMAIL = (process.env.ADMIN_EMAIL || 'nmemmert@duck.com').toLowerCase();
|
||||
|
||||
export function isValidEmail(email) {
|
||||
return typeof email === 'string' && email.length <= 254 && EMAIL_RE.test(email);
|
||||
}
|
||||
|
||||
export function isValidPassword(password) {
|
||||
return typeof password === 'string' && password.length >= 8 && password.length <= 200;
|
||||
}
|
||||
|
||||
export function hashPassword(password) {
|
||||
return bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
export function verifyPassword(password, hash) {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
/** Blocks the request unless a logged-in session is present. */
|
||||
export function requireAuth(req, res, next) {
|
||||
if (!req.session?.userId) {
|
||||
return res.status(401).json({ error: 'Not signed in.' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function isAdminEmail(email) {
|
||||
return typeof email === 'string' && email.toLowerCase() === ADMIN_EMAIL;
|
||||
}
|
||||
|
||||
/** Blocks the request unless the signed-in account is the designated admin. */
|
||||
export function requireAdmin(req, res, next) {
|
||||
const user = req.session?.userId ? getUserById(req.session.userId) : null;
|
||||
if (!user || !isAdminEmail(user.email)) {
|
||||
return res.status(403).json({ error: 'Admin access only.' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Two-factor auth (TOTP, RFC 6238 — compatible with any authenticator app)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function generateTotpSecret() {
|
||||
return authenticator.generateSecret();
|
||||
}
|
||||
|
||||
export function totpKeyUri(email, secret) {
|
||||
return authenticator.keyuri(email, 'Bible Study Project', secret);
|
||||
}
|
||||
|
||||
export function verifyTotpToken(token, secret) {
|
||||
if (typeof token !== 'string' || !/^\d{6}$/.test(token)) return false;
|
||||
try {
|
||||
return authenticator.verify({ token, secret });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns { codes: string[] } plaintext codes to show the user once, for hashBackupCodes(). */
|
||||
export function generateBackupCodes(count = 8) {
|
||||
return Array.from({ length: count }, () => randomBytes(5).toString('hex'));
|
||||
}
|
||||
|
||||
export async function hashBackupCodes(codes) {
|
||||
return Promise.all(codes.map((code) => bcrypt.hash(code, 10)));
|
||||
}
|
||||
|
||||
/** Checks a submitted backup code against stored hashes; returns the remaining hashes if it matched, else null. */
|
||||
export async function consumeBackupCode(submitted, hashes) {
|
||||
if (typeof submitted !== 'string' || !Array.isArray(hashes)) return null;
|
||||
const normalized = submitted.trim().toLowerCase();
|
||||
for (let i = 0; i < hashes.length; i++) {
|
||||
if (await bcrypt.compare(normalized, hashes[i])) {
|
||||
return [...hashes.slice(0, i), ...hashes.slice(i + 1)];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A random temporary password for admin-assisted resets — shown once, relayed to the user out-of-band. */
|
||||
export function generateTemporaryPassword() {
|
||||
return randomBytes(6).toString('hex');
|
||||
}
|
||||
|
||||
// A one-time password-reset code: 8 characters from an alphabet without look-alikes (no 0/O, 1/I/L).
|
||||
const RESET_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||
export function generateResetCode() {
|
||||
return Array.from(randomBytes(8), (b) => RESET_ALPHABET[b % RESET_ALPHABET.length]).join('');
|
||||
}
|
||||
export const normalizeResetCode = (code) => String(code ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
@@ -0,0 +1,62 @@
|
||||
// Automatic daily snapshots of the whole database (every account), kept next to it in DATA_DIR/backups.
|
||||
// A snapshot is a complete, consistent copy made while the app keeps running, and is restored with
|
||||
// scripts/restore-data.sh. BACKUP_KEEP sets how many to keep (default 7); BACKUP_DISABLED=1 turns it off.
|
||||
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { logEvent } from './health.js';
|
||||
import { backupDatabase, dataDir, replaceDatabase } from './db.js';
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
export const backupDir = () => join(dataDir(), 'backups');
|
||||
const stamp = (d) => d.toISOString().replace(/[:T]/g, '-').slice(0, 16);
|
||||
export const isSnapshot = (name) => /^projects-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}\.db$/.test(name);
|
||||
|
||||
export function listSnapshots(dir = backupDir()) {
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir).filter(isSnapshot).sort().reverse()
|
||||
.map((name) => ({ name, bytes: statSync(join(dir, name)).size, at: statSync(join(dir, name)).mtimeMs }));
|
||||
}
|
||||
|
||||
// Deletes all but the newest `keep` snapshots; returns the names removed.
|
||||
export function pruneSnapshots(keep = 7, dir = backupDir()) {
|
||||
const old = listSnapshots(dir).slice(Math.max(1, keep));
|
||||
old.forEach((s) => unlinkSync(join(dir, s.name)));
|
||||
return old.map((s) => s.name);
|
||||
}
|
||||
|
||||
export async function runBackup(now = new Date()) {
|
||||
const dir = backupDir();
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const file = join(dir, `projects-${stamp(now)}.db`);
|
||||
await backupDatabase(file);
|
||||
pruneSnapshots(Number(process.env.BACKUP_KEEP) || 7, dir);
|
||||
return file;
|
||||
}
|
||||
|
||||
export const snapshotPath = (name) => (isSnapshot(name) && existsSync(join(backupDir(), name)) ? join(backupDir(), name) : null);
|
||||
|
||||
// Puts a snapshot back as the live database. A fresh snapshot of what is there now is taken first (from a
|
||||
// copy of the chosen file, so the two can't collide), so a restore can itself be undone.
|
||||
export async function restoreSnapshot(name, now = new Date()) {
|
||||
const src = snapshotPath(name);
|
||||
if (!src) throw new Error('No such snapshot.');
|
||||
const hold = join(backupDir(), `.restoring-${Date.now()}.tmp`);
|
||||
copyFileSync(src, hold);
|
||||
try {
|
||||
const before = await runBackup(now);
|
||||
replaceDatabase(hold);
|
||||
return { restored: name, safetyCopy: before };
|
||||
} finally { unlinkSync(hold); }
|
||||
}
|
||||
|
||||
// Takes a snapshot when the newest is more than a day old (checked hourly, and once at start-up).
|
||||
export function startBackupScheduler() {
|
||||
if (process.env.BACKUP_DISABLED === '1') return;
|
||||
const tick = () => {
|
||||
const latest = listSnapshots()[0];
|
||||
if (latest && Date.now() - latest.at < DAY) return;
|
||||
runBackup().then((f) => console.log(`Database snapshot saved: ${f}`)).catch((err) => { console.error('Database snapshot failed:', err.message); logEvent('backup', err.message); });
|
||||
};
|
||||
setTimeout(tick, 30_000).unref();
|
||||
setInterval(tick, 60 * 60 * 1000).unref();
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// Proxy for licensed translations (NLT, CSB, NASB) served by api.bible.
|
||||
// The API key stays on the server (API_BIBLE_KEY, loaded from .env if present);
|
||||
// the browser only ever talks to /api/bible/...
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Minimal .env loader so the key doesn't have to be exported in every launcher
|
||||
// (setup.sh, systemd, docker). Real environment variables win.
|
||||
const envPath = join(__dirname, '..', '.env');
|
||||
if (existsSync(envPath)) {
|
||||
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
|
||||
}
|
||||
}
|
||||
|
||||
const API_BASE = 'https://rest.api.bible/v1';
|
||||
|
||||
// Canonical book order (index + 1 = the book number the ESV API uses in ranges).
|
||||
const BOOK_ORDER = ['GEN','EXO','LEV','NUM','DEU','JOS','JDG','RUT','1SA','2SA','1KI','2KI','1CH','2CH','EZR','NEH','EST','JOB','PSA','PRO','ECC','SNG','ISA','JER','LAM','EZK','DAN','HOS','JOL','AMO','OBA','JON','MIC','NAM','HAB','ZEP','HAG','ZEC','MAL','MAT','MRK','LUK','JHN','ACT','ROM','1CO','2CO','GAL','EPH','PHP','COL','1TH','2TH','1TI','2TI','TIT','PHM','HEB','JAS','1PE','2PE','1JN','2JN','3JN','JUD','REV'];
|
||||
|
||||
// ESV search returns "Psalm 23:1"-style references; map the names back to abbreviations.
|
||||
const ESV_NAMES = ['Genesis','Exodus','Leviticus','Numbers','Deuteronomy','Joshua','Judges','Ruth','1 Samuel','2 Samuel','1 Kings','2 Kings','1 Chronicles','2 Chronicles','Ezra','Nehemiah','Esther','Job','Psalm','Proverbs','Ecclesiastes','Song of Solomon','Isaiah','Jeremiah','Lamentations','Ezekiel','Daniel','Hosea','Joel','Amos','Obadiah','Jonah','Micah','Nahum','Habakkuk','Zephaniah','Haggai','Zechariah','Malachi','Matthew','Mark','Luke','John','Acts','Romans','1 Corinthians','2 Corinthians','Galatians','Ephesians','Philippians','Colossians','1 Thessalonians','2 Thessalonians','1 Timothy','2 Timothy','Titus','Philemon','Hebrews','James','1 Peter','2 Peter','1 John','2 John','3 John','Jude','Revelation'];
|
||||
const ABBREV_BY_NAME = new Map(ESV_NAMES.map((n, i) => [n.toLowerCase(), BOOK_ORDER[i]]));
|
||||
ABBREV_BY_NAME.set('psalms', 'PSA');
|
||||
ABBREV_BY_NAME.set('song of songs', 'SNG');
|
||||
|
||||
// The full book name for a USFM code ("JHN" -> "John"), or null.
|
||||
export function bookNameFor(abbrev) {
|
||||
const i = BOOK_ORDER.indexOf(String(abbrev ?? '').toUpperCase());
|
||||
return i >= 0 ? ESV_NAMES[i] : null;
|
||||
}
|
||||
|
||||
export function parseEsvReference(ref) {
|
||||
const m = String(ref).match(/^(.+?)\s+(\d+):(\d+)/);
|
||||
const bookAbbrev = m && ABBREV_BY_NAME.get(m[1].toLowerCase());
|
||||
return bookAbbrev ? { bookAbbrev, chapter: Number(m[2]), verse: Number(m[3]) } : null;
|
||||
}
|
||||
|
||||
export const LICENSED_TRANSLATIONS = {
|
||||
ESV: { provider: 'esv', name: 'English Standard Version' },
|
||||
NLT: { provider: 'api.bible', bibleId: 'd6e14a625393b4da-01', name: 'New Living Translation' },
|
||||
CSB: { provider: 'api.bible', bibleId: 'a556c5305ee15c3f-01', name: 'Christian Standard Bible' },
|
||||
NASB: { provider: 'api.bible', bibleId: 'b8ee27bcd1cae43a-01', name: 'New American Standard Bible (1995)' },
|
||||
};
|
||||
|
||||
const CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||
const CACHE_MAX = 1000;
|
||||
// Crossway limits how much ESV text an app may store, so ESV chapters are only
|
||||
// held briefly and in small numbers (enough to absorb repeat loads).
|
||||
const ESV_CACHE_TTL = 60 * 60 * 1000;
|
||||
const ESV_CACHE_MAX = 20;
|
||||
const cache = new Map(); // "NLT/PSA/23" -> { at, payload }
|
||||
|
||||
// api.bible returns plain text with inline "[12]" verse markers, poetry line
|
||||
// breaks, and stray "#" / nbsp characters. Split it back into { number, text }.
|
||||
export function parseChapterText(content) {
|
||||
const clean = String(content ?? '')
|
||||
.replace(/\u00a0/g, ' ')
|
||||
.replace(/#/g, '');
|
||||
const parts = clean.split(/\[(\d+)\]/);
|
||||
const verses = [];
|
||||
for (let i = 1; i < parts.length; i += 2) {
|
||||
const text = parts[i + 1].replace(/\s+/g, ' ').trim().replace(/\s+,$/, '');
|
||||
if (text) verses.push({ number: Number(parts[i]), text });
|
||||
}
|
||||
return verses;
|
||||
}
|
||||
|
||||
export function registerBibleRoutes(app, requireAuth) {
|
||||
app.get('/api/bible/translations', requireAuth, (_req, res) => {
|
||||
res.json(Object.entries(LICENSED_TRANSLATIONS).map(([id, t]) => ({
|
||||
id, name: t.name, available: Boolean(t.provider === 'esv' ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY),
|
||||
})));
|
||||
});
|
||||
|
||||
// Whole-Bible search for the licensed translations, proxied so the keys stay
|
||||
// server-side. Results are { bookAbbrev, chapter, verse, text }; nothing is cached.
|
||||
const SEARCH_PAGE_SIZE = 25;
|
||||
app.get('/api/bible/:translation/search', requireAuth, async (req, res) => {
|
||||
const id = req.params.translation.toUpperCase();
|
||||
const translation = LICENSED_TRANSLATIONS[id];
|
||||
const q = String(req.query.q ?? '').trim();
|
||||
const page = Math.max(1, Math.min(200, parseInt(req.query.page, 10) || 1));
|
||||
if (!translation) return res.status(404).json({ error: 'Unknown translation.' });
|
||||
if (q.length < 2 || q.length > 100) return res.status(400).json({ error: 'Search must be 2 to 100 characters.' });
|
||||
const isEsv = translation.provider === 'esv';
|
||||
const apiKey = isEsv ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY;
|
||||
if (!apiKey) return res.status(503).json({ error: `${isEsv ? 'ESV_API_KEY' : 'API_BIBLE_KEY'} is not configured on the server.` });
|
||||
|
||||
try {
|
||||
let results;
|
||||
let total;
|
||||
if (isEsv) {
|
||||
const qs = new URLSearchParams({ q, 'page-size': String(SEARCH_PAGE_SIZE), page: String(page) });
|
||||
const upstream = await fetch(`https://api.esv.org/v3/passage/search/?${qs}`, { headers: { Authorization: `Token ${apiKey}` } });
|
||||
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
|
||||
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
|
||||
const body = await upstream.json();
|
||||
total = body.total_results ?? 0;
|
||||
results = (body.results ?? []).map((r) => {
|
||||
const ref = parseEsvReference(r.reference);
|
||||
return ref && { ...ref, text: String(r.content ?? '').replace(/<[^>]+>/g, '').trim() };
|
||||
}).filter(Boolean);
|
||||
} else {
|
||||
const qs = new URLSearchParams({ query: q, limit: String(SEARCH_PAGE_SIZE), offset: String((page - 1) * SEARCH_PAGE_SIZE), sort: 'canonical' });
|
||||
const upstream = await fetch(`${API_BASE}/bibles/${translation.bibleId}/search?${qs}`, { headers: { 'api-key': apiKey } });
|
||||
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
|
||||
const body = await upstream.json();
|
||||
total = body.data?.total ?? 0;
|
||||
results = (body.data?.verses ?? []).map((v) => ({
|
||||
bookAbbrev: v.bookId,
|
||||
chapter: Number(String(v.chapterId).split('.')[1]),
|
||||
verse: Number(String(v.id).split('.')[2]),
|
||||
text: String(v.text ?? '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(),
|
||||
})).filter((r) => r.bookAbbrev && r.chapter && r.verse);
|
||||
}
|
||||
res.json({ translation: id, query: q, page, total, hasMore: page * SEARCH_PAGE_SIZE < total, results });
|
||||
} catch {
|
||||
res.status(502).json({ error: 'Could not reach the Bible provider.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ESV chapter audio (read by David Cochran Heath). The ESV API answers with a
|
||||
// redirect to a public mp3; hand that URL to the browser's <audio> element.
|
||||
app.get('/api/bible/ESV/audio/:book/:chapter', requireAuth, async (req, res) => {
|
||||
const { book, chapter } = req.params;
|
||||
const bookNum = BOOK_ORDER.indexOf(book.toUpperCase()) + 1;
|
||||
if (!bookNum || !/^\d{1,3}$/.test(chapter)) return res.status(400).json({ error: 'Invalid book or chapter.' });
|
||||
if (!process.env.ESV_API_KEY) return res.status(503).json({ error: 'ESV_API_KEY is not configured on the server.' });
|
||||
const prefix = String(bookNum).padStart(2, '0') + String(Number(chapter)).padStart(3, '0');
|
||||
try {
|
||||
const upstream = await fetch(`https://api.esv.org/v3/passage/audio/?q=${prefix}001-${prefix}999`, {
|
||||
headers: { Authorization: `Token ${process.env.ESV_API_KEY}` }, redirect: 'manual',
|
||||
});
|
||||
const url = upstream.headers.get('location');
|
||||
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
|
||||
if (!url) return res.status(502).json({ error: 'ESV audio is not available for this chapter.' });
|
||||
res.json({ url });
|
||||
} catch {
|
||||
res.status(502).json({ error: 'Could not reach the ESV audio service.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/bible/:translation/:book/:chapter', requireAuth, async (req, res) => {
|
||||
const translation = LICENSED_TRANSLATIONS[req.params.translation.toUpperCase()];
|
||||
const { book, chapter } = req.params;
|
||||
if (!translation) return res.status(404).json({ error: 'Unknown translation.' });
|
||||
if (!/^[1-3A-Z]{3}$/i.test(book) || !/^\d{1,3}$/.test(chapter)) {
|
||||
return res.status(400).json({ error: 'Invalid book or chapter.' });
|
||||
}
|
||||
const id = req.params.translation.toUpperCase();
|
||||
const isEsv = translation.provider === 'esv';
|
||||
const apiKey = isEsv ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY;
|
||||
if (!apiKey) return res.status(503).json({ error: `${isEsv ? 'ESV_API_KEY' : 'API_BIBLE_KEY'} is not configured on the server.` });
|
||||
|
||||
const key = `${id}/${book.toUpperCase()}/${chapter}`;
|
||||
const ttl = isEsv ? ESV_CACHE_TTL : CACHE_TTL;
|
||||
const hit = cache.get(key);
|
||||
if (hit && Date.now() - hit.at < ttl) return res.json(hit.payload);
|
||||
|
||||
try {
|
||||
let payload;
|
||||
if (isEsv) {
|
||||
const bookNum = BOOK_ORDER.indexOf(book.toUpperCase()) + 1;
|
||||
if (!bookNum) return res.status(400).json({ error: 'Invalid book.' });
|
||||
const prefix = String(bookNum).padStart(2, '0') + String(Number(chapter)).padStart(3, '0');
|
||||
const qs = new URLSearchParams({
|
||||
q: `${prefix}001-${prefix}999`,
|
||||
'include-passage-references': 'false', 'include-verse-numbers': 'true', 'include-first-verse-numbers': 'true',
|
||||
'include-footnotes': 'false', 'include-headings': 'false', 'include-short-copyright': 'false',
|
||||
'include-passage-horizontal-lines': 'false', 'include-heading-horizontal-lines': 'false',
|
||||
});
|
||||
const upstream = await fetch(`https://api.esv.org/v3/passage/text/?${qs}`, { headers: { Authorization: `Token ${apiKey}` } });
|
||||
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
|
||||
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
|
||||
const body = await upstream.json();
|
||||
const verses = parseChapterText(body.passages?.[0]);
|
||||
if (verses.length === 0) return res.status(404).json({ error: 'Chapter not found.' });
|
||||
payload = { translation: id, verses, copyright: '', fumsToken: null };
|
||||
} else {
|
||||
const qs = new URLSearchParams({
|
||||
'content-type': 'text', 'include-notes': 'false', 'include-titles': 'false',
|
||||
'include-chapter-numbers': 'false', 'include-verse-numbers': 'true', 'include-verse-spans': 'false',
|
||||
});
|
||||
const upstream = await fetch(
|
||||
`${API_BASE}/bibles/${translation.bibleId}/chapters/${book.toUpperCase()}.${chapter}?${qs}`,
|
||||
{ headers: { 'api-key': apiKey } },
|
||||
);
|
||||
if (upstream.status === 404) return res.status(404).json({ error: 'Chapter not found.' });
|
||||
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
|
||||
const body = await upstream.json();
|
||||
const verses = parseChapterText(body.data?.content);
|
||||
if (verses.length === 0) return res.status(502).json({ error: 'Bible provider returned no verses.' });
|
||||
payload = { translation: id, verses, copyright: body.data?.copyright ?? '', fumsToken: body.meta?.fumsToken ?? null };
|
||||
}
|
||||
const max = isEsv ? ESV_CACHE_MAX : CACHE_MAX;
|
||||
if (cache.size >= CACHE_MAX + ESV_CACHE_MAX) cache.delete(cache.keys().next().value);
|
||||
if (isEsv) {
|
||||
const esvKeys = [...cache.keys()].filter((k) => k.startsWith('ESV/'));
|
||||
if (esvKeys.length >= max) cache.delete(esvKeys[0]);
|
||||
}
|
||||
cache.set(key, { at: Date.now(), payload });
|
||||
res.json(payload);
|
||||
} catch {
|
||||
res.status(502).json({ error: 'Could not reach the Bible provider.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { parseChapterText, parseEsvReference } from './bibleApi.js';
|
||||
|
||||
test('parseChapterText splits inline verse markers and cleans provider artifacts', () => {
|
||||
const text = ' [1] The Lord is my shepherd;\n I have what I need.\n [2] He lets me lie down #— in pastures, ,\n';
|
||||
expect(parseChapterText(text)).toEqual([
|
||||
{ number: 1, text: 'The Lord is my shepherd; I have what I need.' },
|
||||
{ number: 2, text: 'He lets me lie down — in pastures,' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseChapterText ignores headings before the first verse', () => {
|
||||
expect(parseChapterText('Psalm 23\n\nA Psalm of David.\n\n[1] The LORD is my shepherd;')).toEqual([
|
||||
{ number: 1, text: 'The LORD is my shepherd;' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEsvReference maps ESV book names back to abbreviations', () => {
|
||||
expect(parseEsvReference('Psalm 23:1')).toEqual({ bookAbbrev: 'PSA', chapter: 23, verse: 1 });
|
||||
expect(parseEsvReference('1 John 3:16-18')).toEqual({ bookAbbrev: '1JN', chapter: 3, verse: 16 });
|
||||
expect(parseEsvReference('Song of Solomon 2:4')).toEqual({ bookAbbrev: 'SNG', chapter: 2, verse: 4 });
|
||||
expect(parseEsvReference('Not a book 1:1')).toBeNull();
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { copyFileSync, mkdirSync, rmSync } from 'fs';
|
||||
import { MAX_VERSIONS, pack, shouldSnapshot, summarizeProject, unpack } from './versions.js';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
@@ -9,8 +10,27 @@ const DB_PATH = join(DATA_DIR, 'projects.db');
|
||||
|
||||
let db;
|
||||
|
||||
export const dataDir = () => DATA_DIR;
|
||||
|
||||
/** A consistent copy of the whole database, made while it is in use. */
|
||||
export function backupDatabase(destPath) {
|
||||
return db.backup(destPath);
|
||||
}
|
||||
|
||||
/** Swaps the live database for a copy of `srcFile` (a snapshot). The connection is reopened either way. */
|
||||
export function replaceDatabase(srcFile) {
|
||||
db.close();
|
||||
try {
|
||||
copyFileSync(srcFile, DB_PATH);
|
||||
rmSync(`${DB_PATH}-wal`, { force: true });
|
||||
rmSync(`${DB_PATH}-shm`, { force: true });
|
||||
} finally {
|
||||
initDb();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init — create tables if they don't exist
|
||||
// Init — create tables if they don't exist, migrate older schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
export function initDb() {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
@@ -27,6 +47,220 @@ export function initDb() {
|
||||
chapter_summary TEXT,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
sid TEXT PRIMARY KEY,
|
||||
sess TEXT NOT NULL,
|
||||
expires INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS passkeys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
public_key TEXT NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT,
|
||||
name TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_passkeys_user ON passkeys(user_id);
|
||||
`);
|
||||
|
||||
// Older databases predate multi-user support — add the ownership column.
|
||||
const projectCols = db.prepare('PRAGMA table_info(projects)').all();
|
||||
if (!projectCols.some((c) => c.name === 'user_id')) {
|
||||
db.exec('ALTER TABLE projects ADD COLUMN user_id TEXT REFERENCES users(id)');
|
||||
}
|
||||
|
||||
// Older databases predate 2FA support — add the TOTP columns.
|
||||
const userCols = db.prepare('PRAGMA table_info(users)').all();
|
||||
if (!userCols.some((c) => c.name === 'totp_secret')) {
|
||||
db.exec(`
|
||||
ALTER TABLE users ADD COLUMN totp_secret TEXT;
|
||||
ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN backup_codes TEXT;
|
||||
`);
|
||||
}
|
||||
|
||||
// Older databases predate the podcast-name profile setting.
|
||||
if (!userCols.some((c) => c.name === 'podcast_name')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN podcast_name TEXT');
|
||||
}
|
||||
|
||||
// Older databases predate ntfy.sh daily reading reminders.
|
||||
if (!userCols.some((c) => c.name === 'ntfy_url')) {
|
||||
db.exec(`
|
||||
ALTER TABLE users ADD COLUMN ntfy_url TEXT;
|
||||
ALTER TABLE users ADD COLUMN ntfy_topic TEXT;
|
||||
ALTER TABLE users ADD COLUMN reminder_time TEXT;
|
||||
ALTER TABLE users ADD COLUMN reminder_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN last_reminder_date TEXT;
|
||||
`);
|
||||
}
|
||||
|
||||
// Optional "new sign-in" notification, sent to the same ntfy topic.
|
||||
if (!userCols.some((c) => c.name === 'login_alerts')) db.exec('ALTER TABLE users ADD COLUMN login_alerts INTEGER NOT NULL DEFAULT 0');
|
||||
|
||||
// Optional daily verse of the day, sent to the same ntfy topic.
|
||||
if (!userCols.some((c) => c.name === 'votd_enabled')) {
|
||||
db.exec(`
|
||||
ALTER TABLE users ADD COLUMN votd_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN votd_time TEXT;
|
||||
ALTER TABLE users ADD COLUMN last_votd_date TEXT;
|
||||
`);
|
||||
}
|
||||
|
||||
// Optional daily prayer reminder, sent to the same ntfy topic.
|
||||
if (!userCols.some((c) => c.name === 'prayer_enabled')) {
|
||||
db.exec(`
|
||||
ALTER TABLE users ADD COLUMN prayer_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN prayer_time TEXT;
|
||||
ALTER TABLE users ADD COLUMN last_prayer_date TEXT;
|
||||
`);
|
||||
}
|
||||
|
||||
// Reminders fire at the user's own local time, so remember their timezone.
|
||||
if (!userCols.some((c) => c.name === 'reminder_tz')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN reminder_tz TEXT');
|
||||
}
|
||||
|
||||
// Older databases predate shareable read-only links.
|
||||
if (!projectCols.some((c) => c.name === 'share_token')) {
|
||||
db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT');
|
||||
}
|
||||
if (!projectCols.some((c) => c.name === 'outline_share_token')) {
|
||||
db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT');
|
||||
}
|
||||
// Share links can expire and be protected by a passcode.
|
||||
for (const col of ['share_expires INTEGER', 'share_pass TEXT', 'share_reveal INTEGER', 'outline_share_expires INTEGER', 'outline_share_pass TEXT']) {
|
||||
if (!projectCols.some((c) => c.name === col.split(' ')[0])) db.exec(`ALTER TABLE projects ADD COLUMN ${col}`);
|
||||
}
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL');
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
|
||||
|
||||
// Server-wide settings (who may sign up, ...) and the invite codes that let someone sign up when sign-up is invite-only.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS invites (
|
||||
code TEXT PRIMARY KEY,
|
||||
note TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
used_by TEXT,
|
||||
used_at INTEGER
|
||||
);
|
||||
`);
|
||||
|
||||
// The prayer journal: one row per account (the whole list as JSON; see server/prayers.js).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS prayers (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
data TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Earlier copies of each study (compressed), for "Restore an earlier version".
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS project_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id TEXT NOT NULL,
|
||||
state_at INTEGER NOT NULL,
|
||||
title TEXT,
|
||||
chunks INTEGER NOT NULL DEFAULT 0,
|
||||
words INTEGER NOT NULL DEFAULT 0,
|
||||
data BLOB NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_versions_project ON project_versions(project_id, state_at);
|
||||
`);
|
||||
|
||||
// Co-teachers: other accounts allowed to edit a study, and the single-use links that let them join.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS collaborators (
|
||||
project_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
added_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (project_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_collaborators_user ON collaborators(user_id);
|
||||
CREATE TABLE IF NOT EXISTS edit_invites (
|
||||
token TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
used_by TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
// What the person has read in the Reader (chapters and days), merged from every device.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reader_history (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
data TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// One-time codes for "forgot password": hashed, short-lived, limited tries.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS password_resets (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_hash TEXT NOT NULL,
|
||||
expires INTEGER NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
// Reader ink — one row per user + book + chapter, stored as JSON
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reader_ink (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
book_abbrev TEXT NOT NULL,
|
||||
chapter INTEGER NOT NULL,
|
||||
strokes TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, book_abbrev, chapter)
|
||||
);
|
||||
`);
|
||||
|
||||
// Display preferences (theme, default translation, layout, ...) — one row per user.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_prefs (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
data TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Reader bookmarks + text highlights — one row per user (small JSON), so they follow the
|
||||
// user across devices. updated_at lets a device tell whether the server copy is newer.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reader_marks (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
data TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Reading plan progress — one row per user, holding the whole list of
|
||||
// in-progress/completed plans as JSON (small; no need to normalize).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reading_progress (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
data TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
console.log(`SQLite database ready at ${DB_PATH}`);
|
||||
@@ -42,26 +276,349 @@ function buildSummary(project) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// Users
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns all project summaries (id, title, lastEdited, chapterSummary).
|
||||
* Does NOT return full project data to keep the response small.
|
||||
*/
|
||||
export function getAllProjects() {
|
||||
const rows = db.prepare(`
|
||||
SELECT id, title, last_edited AS lastEdited, chapter_summary AS chapterSummary
|
||||
FROM projects
|
||||
ORDER BY last_edited DESC
|
||||
export function countUsers() {
|
||||
return db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
|
||||
}
|
||||
|
||||
export function createUser({ id, email, passwordHash }) {
|
||||
const createdAt = Date.now();
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, email, password_hash, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(id, email, passwordHash, createdAt);
|
||||
return { id, email, createdAt };
|
||||
}
|
||||
|
||||
function parseUserRow(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
...row,
|
||||
totpEnabled: !!row.totpEnabled,
|
||||
reminderEnabled: !!row.reminderEnabled,
|
||||
votdEnabled: !!row.votdEnabled,
|
||||
prayerEnabled: !!row.prayerEnabled,
|
||||
loginAlerts: !!row.loginAlerts,
|
||||
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
|
||||
};
|
||||
}
|
||||
|
||||
const USER_SELECT = `
|
||||
SELECT id, email, password_hash AS passwordHash, created_at AS createdAt,
|
||||
totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw,
|
||||
podcast_name AS podcastName,
|
||||
ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
|
||||
reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz,
|
||||
votd_enabled AS votdEnabled, votd_time AS votdTime, last_votd_date AS lastVotdDate,
|
||||
prayer_enabled AS prayerEnabled, prayer_time AS prayerTime, login_alerts AS loginAlerts
|
||||
FROM users
|
||||
`;
|
||||
|
||||
export function getUserByEmail(email) {
|
||||
return parseUserRow(db.prepare(`${USER_SELECT} WHERE email = ?`).get(email));
|
||||
}
|
||||
|
||||
export function getUserById(id) {
|
||||
return parseUserRow(db.prepare(`${USER_SELECT} WHERE id = ?`).get(id));
|
||||
}
|
||||
|
||||
/** Persists a confirmed TOTP secret + one-time backup code hashes, turning 2FA on. */
|
||||
export function enableTotp(userId, secret, backupCodeHashes) {
|
||||
db.prepare(`
|
||||
UPDATE users SET totp_secret = ?, totp_enabled = 1, backup_codes = ? WHERE id = ?
|
||||
`).run(secret, JSON.stringify(backupCodeHashes), userId);
|
||||
}
|
||||
|
||||
/** Turns 2FA off and forgets the secret/backup codes entirely. */
|
||||
export function disableTotp(userId) {
|
||||
db.prepare(`
|
||||
UPDATE users SET totp_secret = NULL, totp_enabled = 0, backup_codes = NULL WHERE id = ?
|
||||
`).run(userId);
|
||||
}
|
||||
|
||||
/** Sets or clears the show name used in the "Prepare for Podcast" prompt. */
|
||||
export function setPodcastName(userId, podcastName) {
|
||||
db.prepare('UPDATE users SET podcast_name = ? WHERE id = ?').run(podcastName || null, userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Daily reading reminders (sent via ntfy.sh — see server/reminders.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Saves a user's ntfy reminder settings ({ ntfyUrl, ntfyTopic, reminderTime, reminderEnabled }). */
|
||||
export function setReminderSettings(userId, { ntfyUrl, ntfyTopic, reminderTime, reminderEnabled, reminderTz }) {
|
||||
db.prepare(`
|
||||
UPDATE users SET ntfy_url = ?, ntfy_topic = ?, reminder_time = ?, reminder_enabled = ?, reminder_tz = ?
|
||||
WHERE id = ?
|
||||
`).run(ntfyUrl || null, ntfyTopic || null, reminderTime || null, reminderEnabled ? 1 : 0, reminderTz || null, userId);
|
||||
}
|
||||
|
||||
/** Turns the daily verse of the day on or off, and says when to send it. */
|
||||
export function setVotdSettings(userId, { votdEnabled, votdTime }) {
|
||||
db.prepare('UPDATE users SET votd_enabled = ?, votd_time = ? WHERE id = ?').run(votdEnabled ? 1 : 0, votdTime || null, userId);
|
||||
}
|
||||
|
||||
/** Turns the daily prayer reminder on or off, and says when to send it. */
|
||||
export function setPrayerReminder(userId, { prayerEnabled, prayerTime }) {
|
||||
db.prepare('UPDATE users SET prayer_enabled = ?, prayer_time = ? WHERE id = ?').run(prayerEnabled ? 1 : 0, prayerTime || null, userId);
|
||||
}
|
||||
|
||||
export function getUsersWithPrayerReminder() {
|
||||
return db.prepare(`
|
||||
SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, prayer_time AS prayerTime,
|
||||
last_prayer_date AS lastPrayerDate, reminder_tz AS reminderTz
|
||||
FROM users
|
||||
WHERE prayer_enabled = 1 AND ntfy_topic IS NOT NULL AND prayer_time IS NOT NULL
|
||||
`).all();
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function setLastPrayerDate(userId, dateString) {
|
||||
db.prepare('UPDATE users SET last_prayer_date = ? WHERE id = ?').run(dateString, userId);
|
||||
}
|
||||
|
||||
export function getPrayers(userId) {
|
||||
const row = db.prepare('SELECT data FROM prayers WHERE user_id = ?').get(userId);
|
||||
try { return row ? JSON.parse(row.data) : []; } catch { return []; }
|
||||
}
|
||||
|
||||
export function setPrayers(userId, list) {
|
||||
db.prepare(`
|
||||
INSERT INTO prayers (user_id, data, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
|
||||
`).run(userId, JSON.stringify(list), Date.now());
|
||||
}
|
||||
|
||||
/** Every account with the verse of the day turned on and a topic to send it to. */
|
||||
export function getUsersWithVotdEnabled() {
|
||||
return db.prepare(`
|
||||
SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, votd_time AS votdTime,
|
||||
last_votd_date AS lastVotdDate, reminder_tz AS reminderTz
|
||||
FROM users
|
||||
WHERE votd_enabled = 1 AND ntfy_topic IS NOT NULL AND votd_time IS NOT NULL
|
||||
`).all();
|
||||
}
|
||||
|
||||
export function setLastVotdDate(userId, dateString) {
|
||||
db.prepare('UPDATE users SET last_votd_date = ? WHERE id = ?').run(dateString, userId);
|
||||
}
|
||||
|
||||
/** Every account with reminders turned on and a topic configured — checked once a minute. */
|
||||
export function getUsersWithRemindersEnabled() {
|
||||
return db.prepare(`
|
||||
SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
|
||||
last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz
|
||||
FROM users
|
||||
WHERE reminder_enabled = 1 AND ntfy_topic IS NOT NULL AND reminder_time IS NOT NULL
|
||||
`).all();
|
||||
}
|
||||
|
||||
/** Marks that a user's reminder was sent today, so it isn't sent twice. */
|
||||
export function setLastReminderDate(userId, dateString) {
|
||||
db.prepare('UPDATE users SET last_reminder_date = ? WHERE id = ?').run(dateString, userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings and sign-up invites
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getSetting(key, fallback = null) {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
|
||||
return row ? row.value : fallback;
|
||||
}
|
||||
|
||||
export function setSetting(key, value) {
|
||||
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, String(value));
|
||||
}
|
||||
|
||||
export function createInvite(code, note, expiresAt) {
|
||||
db.prepare('INSERT INTO invites (code, note, created_at, expires_at) VALUES (?, ?, ?, ?)').run(code, note || null, Date.now(), expiresAt);
|
||||
}
|
||||
|
||||
export function listInvites() {
|
||||
return db.prepare('SELECT code, note, created_at AS createdAt, expires_at AS expiresAt, used_by AS usedBy, used_at AS usedAt FROM invites ORDER BY created_at DESC LIMIT 100').all();
|
||||
}
|
||||
|
||||
export function deleteInvite(code) {
|
||||
db.prepare('DELETE FROM invites WHERE code = ?').run(code);
|
||||
}
|
||||
|
||||
/** An invite that exists, hasn't expired and hasn't been used. */
|
||||
export function findOpenInvite(code) {
|
||||
return db.prepare('SELECT code FROM invites WHERE code = ? AND used_by IS NULL AND expires_at > ?').get(code, Date.now()) ?? null;
|
||||
}
|
||||
|
||||
/** Marks an invite used by a new account; false if someone else got there first. */
|
||||
export function useInvite(code, userId) {
|
||||
return db.prepare('UPDATE invites SET used_by = ?, used_at = ? WHERE code = ? AND used_by IS NULL AND expires_at > ?').run(userId, Date.now(), code, Date.now()).changes > 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Password reset codes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function setPasswordReset(userId, codeHash, expires) {
|
||||
db.prepare(`
|
||||
INSERT INTO password_resets (user_id, code_hash, expires, attempts) VALUES (?, ?, ?, 0)
|
||||
ON CONFLICT(user_id) DO UPDATE SET code_hash = excluded.code_hash, expires = excluded.expires, attempts = 0
|
||||
`).run(userId, codeHash, expires);
|
||||
}
|
||||
|
||||
export function getPasswordReset(userId) {
|
||||
const row = db.prepare('SELECT code_hash AS codeHash, expires, attempts FROM password_resets WHERE user_id = ?').get(userId);
|
||||
return row && row.expires > Date.now() ? row : null;
|
||||
}
|
||||
|
||||
export function bumpPasswordResetAttempts(userId) {
|
||||
db.prepare('UPDATE password_resets SET attempts = attempts + 1 WHERE user_id = ?').run(userId);
|
||||
}
|
||||
|
||||
export function clearPasswordReset(userId) {
|
||||
db.prepare('DELETE FROM password_resets WHERE user_id = ?').run(userId);
|
||||
}
|
||||
|
||||
/** Rewrites the remaining backup-code hashes after one is used (single-use codes). */
|
||||
export function setBackupCodeHashes(userId, backupCodeHashes) {
|
||||
db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reader ink — cross-device sync for draw-mode annotations in the reader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns all saved reader ink pages for a user as { "BOOK_CH": strokes[] }. */
|
||||
export function getAllReaderInk(userId) {
|
||||
const rows = db.prepare(
|
||||
'SELECT book_abbrev AS book, chapter, strokes FROM reader_ink WHERE user_id = ?'
|
||||
).all(userId);
|
||||
return Object.fromEntries(
|
||||
rows.map((r) => [`${r.book}_${r.chapter}`, JSON.parse(r.strokes)])
|
||||
);
|
||||
}
|
||||
|
||||
/** The user's synced preferences: { key: { v, at } } (empty when none saved yet). */
|
||||
export function getUserPrefs(userId) {
|
||||
const row = db.prepare('SELECT data FROM user_prefs WHERE user_id = ?').get(userId);
|
||||
return row ? JSON.parse(row.data) : {};
|
||||
}
|
||||
|
||||
/** Replaces the user's stored preferences with `prefs` ({ key: { v, at } }). */
|
||||
export function setUserPrefs(userId, prefs) {
|
||||
db.prepare(`
|
||||
INSERT INTO user_prefs (user_id, data, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
|
||||
`).run(userId, JSON.stringify(prefs), Date.now());
|
||||
}
|
||||
|
||||
/** The user's bookmarks + highlights: { bookmarks, highlights, updatedAt } (updatedAt 0 = never saved). */
|
||||
export function getReaderMarks(userId) {
|
||||
const row = db.prepare('SELECT data, updated_at AS updatedAt FROM reader_marks WHERE user_id = ?').get(userId);
|
||||
if (!row) return { bookmarks: {}, highlights: [], bookmarkNotes: {}, updatedAt: 0 };
|
||||
const data = JSON.parse(row.data);
|
||||
return { bookmarks: data.bookmarks ?? {}, highlights: data.highlights ?? [], bookmarkNotes: data.bookmarkNotes ?? {}, updatedAt: row.updatedAt };
|
||||
}
|
||||
|
||||
/** Replaces the user's bookmarks + highlights; returns the new updatedAt (always moves forward). */
|
||||
export function setReaderMarks(userId, { bookmarks, highlights, bookmarkNotes = {} }) {
|
||||
const prev = db.prepare('SELECT updated_at AS updatedAt FROM reader_marks WHERE user_id = ?').get(userId);
|
||||
const updatedAt = Math.max(Date.now(), (prev?.updatedAt ?? 0) + 1);
|
||||
db.prepare(`
|
||||
INSERT INTO reader_marks (user_id, data, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
|
||||
`).run(userId, JSON.stringify({ bookmarks, highlights, bookmarkNotes }), updatedAt);
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
export function getReaderHistory(userId) {
|
||||
const row = db.prepare('SELECT data FROM reader_history WHERE user_id = ?').get(userId);
|
||||
try { return row ? JSON.parse(row.data) : { chapters: {}, days: [] }; } catch { return { chapters: {}, days: [] }; }
|
||||
}
|
||||
|
||||
export function setReaderHistory(userId, history) {
|
||||
db.prepare(`
|
||||
INSERT INTO reader_history (user_id, data, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
|
||||
`).run(userId, JSON.stringify(history), Date.now());
|
||||
}
|
||||
|
||||
/** Upserts the ink strokes for one reader page. */
|
||||
export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) {
|
||||
db.prepare(`
|
||||
INSERT INTO reader_ink (user_id, book_abbrev, chapter, strokes, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, book_abbrev, chapter)
|
||||
DO UPDATE SET strokes = excluded.strokes, updated_at = excluded.updated_at
|
||||
`).run(userId, bookAbbrev, Number(chapter), JSON.stringify(strokes), Date.now());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reading plan progress — cross-device sync for started/completed reading plans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns the user's reading-plan data ({ progress, customPlans }, parsed JSON), or an empty shell. */
|
||||
export function getReadingProgress(userId) {
|
||||
const row = db.prepare('SELECT data FROM reading_progress WHERE user_id = ?').get(userId);
|
||||
if (!row) return { progress: [], customPlans: [] };
|
||||
try {
|
||||
return JSON.parse(row.data);
|
||||
} catch {
|
||||
return { progress: [], customPlans: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Overwrites the user's whole reading-plan progress list. */
|
||||
export function setReadingProgress(userId, progress) {
|
||||
db.prepare(`
|
||||
INSERT INTO reading_progress (user_id, data, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
|
||||
`).run(userId, JSON.stringify(progress), Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single full project by id, or null if not found.
|
||||
* Assigns any pre-existing, unowned projects (from before multi-user support)
|
||||
* to the given user. Intended to run once, right after the first account is created.
|
||||
*/
|
||||
export function getProject(id) {
|
||||
export function claimOrphanProjects(userId) {
|
||||
db.prepare('UPDATE projects SET user_id = ? WHERE user_id IS NULL').run(userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project queries — all scoped to the owning user
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns all project summaries owned by userId (id, title, lastEdited, chapterSummary).
|
||||
* Does NOT return full project data to keep the response small.
|
||||
*/
|
||||
export function getAllProjects(userId) {
|
||||
// the studies you own, plus the ones another account has invited you to edit
|
||||
const rows = db.prepare(`
|
||||
SELECT p.id, p.title, p.last_edited AS lastEdited, p.chapter_summary AS chapterSummary,
|
||||
CASE WHEN p.user_id = ? THEN 0 ELSE 1 END AS shared, u.email AS ownerEmail
|
||||
FROM projects p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
WHERE p.user_id = ? OR p.id IN (SELECT project_id FROM collaborators WHERE user_id = ?)
|
||||
ORDER BY p.last_edited DESC
|
||||
`).all(userId, userId, userId);
|
||||
return rows.map((r) => (r.shared ? { ...r, shared: true } : { id: r.id, title: r.title, lastEdited: r.lastEdited, chapterSummary: r.chapterSummary }));
|
||||
}
|
||||
|
||||
/** True when the account owns the study or has been invited to edit it. */
|
||||
export function canEditProject(id, userId) {
|
||||
return !!db.prepare(`
|
||||
SELECT 1 FROM projects p WHERE p.id = ? AND (p.user_id = ? OR EXISTS (SELECT 1 FROM collaborators c WHERE c.project_id = p.id AND c.user_id = ?))
|
||||
`).get(id, userId, userId);
|
||||
}
|
||||
|
||||
export const isProjectOwner = (id, userId) => !!db.prepare('SELECT 1 FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
|
||||
|
||||
/**
|
||||
* Returns a single full project by id, scoped to userId, or null if not found/not owned.
|
||||
*/
|
||||
export function getProject(id, userId) {
|
||||
if (!canEditProject(id, userId)) return null;
|
||||
const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id);
|
||||
if (!row) return null;
|
||||
try {
|
||||
@@ -71,30 +628,366 @@ export function getProject(id) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The server copy's lastEdited for a project owned by userId, or null when there isn't one. */
|
||||
export function getProjectEdited(id, userId) {
|
||||
if (!canEditProject(id, userId)) return null;
|
||||
const row = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ?').get(id);
|
||||
return row ? row.lastEdited : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or replace a project. Returns the summary.
|
||||
* Insert or replace a project owned by userId.
|
||||
* Returns the summary, or null if the id already belongs to a different user.
|
||||
*/
|
||||
export function upsertProject(project) {
|
||||
const lastEdited = project.lastEdited ?? Date.now();
|
||||
export function upsertProject(project, userId) {
|
||||
const existing = db.prepare('SELECT user_id AS userId FROM projects WHERE id = ?').get(project.id);
|
||||
if (existing && existing.userId !== userId && !canEditProject(project.id, userId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The server's clock decides the version, so a device with a wrong clock can't hide or fake changes.
|
||||
const prev = db.prepare('SELECT last_edited AS lastEdited, data FROM projects WHERE id = ?').get(project.id);
|
||||
if (prev) keepVersion(project.id, prev);
|
||||
const lastEdited = Math.max(Date.now(), (prev?.lastEdited ?? 0) + 1);
|
||||
const chapterSummary = buildSummary(project);
|
||||
const updated = { ...project, lastEdited };
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO projects (id, title, last_edited, chapter_summary, data)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO projects (id, title, last_edited, chapter_summary, data, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
last_edited = excluded.last_edited,
|
||||
chapter_summary = excluded.chapter_summary,
|
||||
data = excluded.data
|
||||
`).run(project.id, project.title, lastEdited, chapterSummary, JSON.stringify(updated));
|
||||
`).run(project.id, project.title, lastEdited, chapterSummary, JSON.stringify(updated), userId);
|
||||
|
||||
return { id: project.id, title: project.title, lastEdited, chapterSummary };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Earlier versions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Keeps the copy that is about to be replaced (when it's been long enough since the last kept one), then trims the oldest. */
|
||||
function keepVersion(projectId, prev, force = false) {
|
||||
const last = db.prepare('SELECT MAX(state_at) AS at FROM project_versions WHERE project_id = ?').get(projectId)?.at;
|
||||
if (!shouldSnapshot({ prevEditedAt: prev.lastEdited, lastKeptAt: last ?? undefined, force })) return;
|
||||
let project;
|
||||
try { project = JSON.parse(prev.data); } catch { return; }
|
||||
if (db.prepare('SELECT 1 FROM project_versions WHERE project_id = ? AND state_at = ?').get(projectId, prev.lastEdited)) return;
|
||||
const { chunks, words } = summarizeProject(project);
|
||||
db.prepare('INSERT INTO project_versions (project_id, state_at, title, chunks, words, data) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(projectId, prev.lastEdited, project.title ?? '', chunks, words, pack(prev.data));
|
||||
db.prepare(`DELETE FROM project_versions WHERE project_id = ? AND id NOT IN (SELECT id FROM project_versions WHERE project_id = ? ORDER BY state_at DESC LIMIT ?)`).run(projectId, projectId, MAX_VERSIONS);
|
||||
}
|
||||
|
||||
export function listVersions(projectId) {
|
||||
return db.prepare('SELECT id, state_at AS savedAt, title, chunks, words FROM project_versions WHERE project_id = ? ORDER BY state_at DESC').all(projectId);
|
||||
}
|
||||
|
||||
export function getVersion(projectId, versionId) {
|
||||
const row = db.prepare('SELECT data FROM project_versions WHERE project_id = ? AND id = ?').get(projectId, versionId);
|
||||
if (!row) return null;
|
||||
try { return unpack(row.data); } catch { return null; }
|
||||
}
|
||||
|
||||
/** Puts an earlier version back as the current one. The copy it replaces is kept first, so a restore can be undone too. */
|
||||
export function restoreVersion(projectId, versionId, userId) {
|
||||
const old = getVersion(projectId, versionId);
|
||||
if (!old || !canEditProject(projectId, userId)) return null;
|
||||
return db.transaction(() => {
|
||||
const prev = db.prepare('SELECT last_edited AS lastEdited, data FROM projects WHERE id = ?').get(projectId);
|
||||
if (prev) keepVersion(projectId, prev, true);
|
||||
const lastEdited = Math.max(Date.now(), (prev?.lastEdited ?? 0) + 1);
|
||||
const restored = { ...old, id: projectId, lastEdited };
|
||||
db.prepare('UPDATE projects SET title = ?, last_edited = ?, chapter_summary = ?, data = ? WHERE id = ?')
|
||||
.run(restored.title, lastEdited, buildSummary(restored), JSON.stringify(restored), projectId);
|
||||
return restored;
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project by id. No-op if not found.
|
||||
* Delete a project by id, scoped to userId. No-op if not found/not owned.
|
||||
*/
|
||||
export function deleteProject(id) {
|
||||
export function deleteProject(id, userId) {
|
||||
db.transaction(() => {
|
||||
if (!db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId).changes) {
|
||||
// not the owner: "deleting" a study you were invited to just removes you from it
|
||||
db.prepare('DELETE FROM collaborators WHERE project_id = ? AND user_id = ?').run(id, userId);
|
||||
return;
|
||||
}
|
||||
db.prepare('DELETE FROM collaborators WHERE project_id = ?').run(id);
|
||||
db.prepare('DELETE FROM edit_invites WHERE project_id = ?').run(id);
|
||||
db.prepare('DELETE FROM project_versions WHERE project_id = ?').run(id);
|
||||
})();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Co-teachers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createEditInvite(token, projectId, expiresAt) {
|
||||
db.prepare('INSERT INTO edit_invites (token, project_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(token, projectId, Date.now(), expiresAt);
|
||||
}
|
||||
|
||||
export function listCollaborators(projectId) {
|
||||
return db.prepare(`
|
||||
SELECT c.user_id AS userId, u.email, c.added_at AS addedAt FROM collaborators c JOIN users u ON u.id = c.user_id
|
||||
WHERE c.project_id = ? ORDER BY c.added_at
|
||||
`).all(projectId);
|
||||
}
|
||||
|
||||
export function countOpenEditInvites(projectId) {
|
||||
return db.prepare('SELECT COUNT(*) AS n FROM edit_invites WHERE project_id = ? AND used_by IS NULL AND expires_at > ?').get(projectId, Date.now()).n;
|
||||
}
|
||||
|
||||
export function removeCollaborator(projectId, userId) {
|
||||
db.prepare('DELETE FROM collaborators WHERE project_id = ? AND user_id = ?').run(projectId, userId);
|
||||
}
|
||||
|
||||
/** Uses a single-use invite: -> { projectId, title } | { error }. The owner is told "you own this" rather than added. */
|
||||
export function acceptEditInvite(token, userId) {
|
||||
return db.transaction(() => {
|
||||
const inv = db.prepare('SELECT project_id AS projectId, expires_at AS expiresAt, used_by AS usedBy FROM edit_invites WHERE token = ?').get(token);
|
||||
if (!inv) return { error: 'This invite link is not valid.' };
|
||||
if (inv.usedBy) return { error: 'This invite link has already been used.' };
|
||||
if (inv.expiresAt <= Date.now()) return { error: 'This invite link has expired. Ask for a new one.' };
|
||||
const project = db.prepare('SELECT title, user_id AS ownerId FROM projects WHERE id = ?').get(inv.projectId);
|
||||
if (!project) return { error: 'That study no longer exists.' };
|
||||
if (project.ownerId === userId) return { error: 'That is your own study.' };
|
||||
db.prepare('INSERT OR IGNORE INTO collaborators (project_id, user_id, added_at) VALUES (?, ?, ?)').run(inv.projectId, userId, Date.now());
|
||||
db.prepare('UPDATE edit_invites SET used_by = ? WHERE token = ?').run(userId, token);
|
||||
return { projectId: inv.projectId, title: project.title };
|
||||
})();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only share links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns the current share token for a project owned by userId, or null. */
|
||||
export function getShareToken(id, userId) {
|
||||
const row = db.prepare('SELECT share_token AS shareToken FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
|
||||
return row?.shareToken ?? null;
|
||||
}
|
||||
|
||||
/** Sets a project's share token (enabling its public read-only link), scoped to userId. */
|
||||
export function setShareToken(id, userId, token) {
|
||||
const result = db.prepare('UPDATE projects SET share_token = ? WHERE id = ? AND user_id = ?').run(token, id, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/** Revokes a project's share link, scoped to userId. */
|
||||
export function clearShareToken(id, userId) {
|
||||
db.prepare('UPDATE projects SET share_token = NULL, share_expires = NULL, share_pass = NULL, share_reveal = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
// ---- read-only outline links: a separate token, so sharing an outline never shares the whole study ----
|
||||
|
||||
export function getOutlineShareToken(id, userId) {
|
||||
const row = db.prepare('SELECT outline_share_token AS t FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
|
||||
return row?.t ?? null;
|
||||
}
|
||||
|
||||
export function setOutlineShareToken(id, userId, token) {
|
||||
return db.prepare('UPDATE projects SET outline_share_token = ? WHERE id = ? AND user_id = ?').run(token, id, userId).changes > 0;
|
||||
}
|
||||
|
||||
export function clearOutlineShareToken(id, userId) {
|
||||
db.prepare('UPDATE projects SET outline_share_token = NULL, outline_share_expires = NULL, outline_share_pass = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
// ---- expiry and passcode for either kind of read-only link (outline = the outline-only link) ----
|
||||
const shareCols = (outline) => (outline
|
||||
? { token: 'outline_share_token', expires: 'outline_share_expires', pass: 'outline_share_pass', reveal: 'NULL' }
|
||||
: { token: 'share_token', expires: 'share_expires', pass: 'share_pass', reveal: 'share_reveal' });
|
||||
|
||||
/** { token, expiresAt, hasPasscode, revealAt } for the owner's link settings (revealAt: notes stay hidden until then). */
|
||||
export function getShareSettings(id, userId, outline = false) {
|
||||
const c = shareCols(outline);
|
||||
const row = db.prepare(`SELECT ${c.token} AS token, ${c.expires} AS expiresAt, ${c.pass} AS pass, ${c.reveal} AS revealAt FROM projects WHERE id = ? AND user_id = ?`).get(id, userId);
|
||||
return row ? { token: row.token ?? null, expiresAt: row.expiresAt ?? null, hasPasscode: !!row.pass, revealAt: row.revealAt ?? null } : null;
|
||||
}
|
||||
|
||||
/** The stored passcode hash of a link (kept when only the expiry is changed). */
|
||||
export function getSharePassHash(id, userId, outline = false) {
|
||||
const c = shareCols(outline);
|
||||
return db.prepare(`SELECT ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId)?.pass ?? null;
|
||||
}
|
||||
|
||||
/** Sets expiry (ms timestamp or null), passcode hash (or null) and, for a study link, when its notes appear (or null). */
|
||||
export function setShareSettings(id, userId, { expiresAt, passHash, revealAt }, outline = false) {
|
||||
const c = shareCols(outline);
|
||||
const changed = db.prepare(`UPDATE projects SET ${c.expires} = ?, ${c.pass} = ? WHERE id = ? AND user_id = ?`).run(expiresAt ?? null, passHash ?? null, id, userId).changes > 0;
|
||||
if (changed && !outline && revealAt !== undefined) db.prepare('UPDATE projects SET share_reveal = ? WHERE id = ? AND user_id = ?').run(revealAt ?? null, id, userId);
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Public lookup for a link: the project plus the link's rules, or null for an unknown token. */
|
||||
export function getSharedByToken(token, outline = false) {
|
||||
const c = shareCols(outline);
|
||||
const row = db.prepare(`SELECT data, ${c.expires} AS expiresAt, ${c.pass} AS passHash, ${c.reveal} AS revealAt FROM projects WHERE ${c.token} = ?`).get(token);
|
||||
if (!row) return null;
|
||||
try { return { project: JSON.parse(row.data), expiresAt: row.expiresAt ?? null, passHash: row.passHash ?? null, revealAt: row.revealAt ?? null }; } catch { return null; }
|
||||
}
|
||||
|
||||
/** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */
|
||||
export function getProjectByOutlineToken(token) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE outline_share_token = ?').get(token);
|
||||
if (!row) return null;
|
||||
try { return JSON.parse(row.data); } catch { return null; }
|
||||
}
|
||||
|
||||
/** Public lookup: returns the full project data for a valid share token, or null. No ownership check — this is the point. */
|
||||
export function getProjectByShareToken(token) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE share_token = ?').get(token);
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin — unscoped views across every user/project. Callers must gate access
|
||||
// themselves (see requireAdmin in server/auth.js); nothing here checks who's asking.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Every account, with a project count, for the admin users list. */
|
||||
export function adminGetAllUsers() {
|
||||
return db.prepare(`
|
||||
SELECT u.id, u.email, u.created_at AS createdAt, u.totp_enabled AS totpEnabled,
|
||||
(SELECT COUNT(*) FROM projects p WHERE p.user_id = u.id) AS projectCount
|
||||
FROM users u
|
||||
ORDER BY u.created_at ASC
|
||||
`).all().map((r) => ({ ...r, totpEnabled: !!r.totpEnabled }));
|
||||
}
|
||||
|
||||
/** Deletes a user account. Their projects are left in place (orphaned, not cascade-deleted) so data isn't lost by accident. */
|
||||
export function adminDeleteUser(userId) {
|
||||
db.prepare('DELETE FROM passkeys WHERE user_id = ?').run(userId);
|
||||
// projects.user_id is a foreign key, so release the projects first (they stay as unowned rows).
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM collaborators WHERE user_id = ?').run(userId);
|
||||
db.prepare('UPDATE projects SET user_id = NULL, share_token = NULL, outline_share_token = NULL WHERE user_id = ?').run(userId);
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||
})();
|
||||
}
|
||||
|
||||
/** Every project across every user, with the owner's email, for the admin projects list. */
|
||||
export function adminGetAllProjects() {
|
||||
return db.prepare(`
|
||||
SELECT p.id, p.title, p.last_edited AS lastEdited, p.chapter_summary AS chapterSummary,
|
||||
p.share_token AS shareToken, u.email AS ownerEmail
|
||||
FROM projects p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
ORDER BY p.last_edited DESC
|
||||
`).all();
|
||||
}
|
||||
|
||||
/** Full project data by id, regardless of owner — for admin inspection. */
|
||||
export function adminGetProject(id) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id);
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes any project by id, regardless of owner. */
|
||||
export function adminDeleteProject(id) {
|
||||
db.prepare('DELETE FROM project_versions WHERE project_id = ?').run(id);
|
||||
db.prepare('DELETE FROM projects WHERE id = ?').run(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Overwrites a user's password hash directly — used for both self-service and admin-assisted resets. */
|
||||
export function setUserPassword(userId, passwordHash) {
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session store backing (used by server/sessionStore.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getSession(sid) {
|
||||
const row = db.prepare('SELECT sess, expires FROM sessions WHERE sid = ?').get(sid);
|
||||
if (!row || row.expires < Date.now()) return null;
|
||||
try {
|
||||
return JSON.parse(row.sess);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setSession(sid, sess, expires) {
|
||||
db.prepare(`
|
||||
INSERT INTO sessions (sid, sess, expires)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expires = excluded.expires
|
||||
`).run(sid, JSON.stringify(sess), expires);
|
||||
}
|
||||
|
||||
export function destroySession(sid) {
|
||||
db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
|
||||
}
|
||||
|
||||
export function pruneExpiredSessions() {
|
||||
db.prepare('DELETE FROM sessions WHERE expires < ?').run(Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a user out everywhere by deleting every session that belongs to them.
|
||||
* Sessions don't have an indexed user_id column (they're just an opaque JSON
|
||||
* blob to express-session), so this scans and parses — fine at this app's scale.
|
||||
* Pass exceptSid to keep one session alive (e.g. the one completing a self-service
|
||||
* password change, so the user isn't immediately logged out of their own action).
|
||||
*/
|
||||
export function destroyAllSessionsForUser(userId, exceptSid = null) {
|
||||
const rows = db.prepare('SELECT sid, sess FROM sessions').all();
|
||||
const staleSids = rows
|
||||
.filter((row) => row.sid !== exceptSid)
|
||||
.filter((row) => {
|
||||
try {
|
||||
return JSON.parse(row.sess)?.userId === userId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.map((row) => row.sid);
|
||||
if (staleSids.length === 0) return;
|
||||
const placeholders = staleSids.map(() => '?').join(',');
|
||||
db.prepare(`DELETE FROM sessions WHERE sid IN (${placeholders})`).run(...staleSids);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Passkeys, sign-in alerts and the list of signed-in devices
|
||||
// ---------------------------------------------------------------------------
|
||||
export function setLoginAlerts(userId, enabled) {
|
||||
db.prepare('UPDATE users SET login_alerts = ? WHERE id = ?').run(enabled ? 1 : 0, userId);
|
||||
}
|
||||
|
||||
export function addPasskey({ id, userId, publicKey, counter, transports, name }) {
|
||||
db.prepare('INSERT INTO passkeys (id, user_id, public_key, counter, transports, name, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(id, userId, publicKey, counter, JSON.stringify(transports ?? []), name, Date.now());
|
||||
}
|
||||
|
||||
const passkeyRow = (r) => (r ? { ...r, transports: JSON.parse(r.transports || '[]') } : null);
|
||||
const PASSKEY_COLS = 'id, user_id AS userId, public_key AS publicKey, counter, transports, name, created_at AS createdAt, last_used_at AS lastUsedAt';
|
||||
|
||||
export const getPasskey = (id) => passkeyRow(db.prepare(`SELECT ${PASSKEY_COLS} FROM passkeys WHERE id = ?`).get(id));
|
||||
export const listPasskeys = (userId) => db.prepare(`SELECT ${PASSKEY_COLS} FROM passkeys WHERE user_id = ? ORDER BY created_at`).all(userId).map(passkeyRow);
|
||||
export const touchPasskey = (id, counter) => db.prepare('UPDATE passkeys SET counter = ?, last_used_at = ? WHERE id = ?').run(counter, Date.now(), id);
|
||||
export const deletePasskey = (id, userId) => db.prepare('DELETE FROM passkeys WHERE id = ? AND user_id = ?').run(id, userId).changes > 0;
|
||||
export const renamePasskey = (id, userId, name) => db.prepare('UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?').run(name, id, userId).changes > 0;
|
||||
|
||||
/** Every live session of one user: [{ sid, sess, expires }]. */
|
||||
export function listUserSessions(userId) {
|
||||
return db.prepare('SELECT sid, sess, expires FROM sessions WHERE expires > ?').all(Date.now())
|
||||
.map((r) => { try { return { sid: r.sid, sess: JSON.parse(r.sess), expires: r.expires }; } catch { return null; } })
|
||||
.filter((r) => r?.sess?.userId === userId);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Passkeys (WebAuthn sign-in with Face ID, a fingerprint or a security key) and the list of devices signed in.
|
||||
// A passkey is a second factor in itself (the device checks it is really you), so signing in with one skips the
|
||||
// authenticator-code step. Passwords and 2FA keep working as before.
|
||||
import { createHash } from 'crypto';
|
||||
import {
|
||||
generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import {
|
||||
addPasskey, deletePasskey, destroyAllSessionsForUser, destroySession, getPasskey, getUserById, listPasskeys,
|
||||
listUserSessions, renamePasskey, setLoginAlerts, touchPasskey,
|
||||
} from './db.js';
|
||||
import { logEvent } from './health.js';
|
||||
import { sendNtfy } from './reminders.js';
|
||||
|
||||
const MONTH = 30 * 24 * 60 * 60 * 1000;
|
||||
const APP_NAME = 'Bible Study';
|
||||
|
||||
// "Chrome on macOS" from a User-Agent string; good enough to recognise your own devices.
|
||||
export function describeDevice(ua = '') {
|
||||
const browser = /Edg\//.test(ua) ? 'Edge' : /OPR\//.test(ua) ? 'Opera' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\/|CriOS\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : null;
|
||||
const os = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Windows/.test(ua) ? 'Windows' : /Mac OS X|Macintosh/.test(ua) ? 'macOS' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : null;
|
||||
if (browser && os) return `${browser} on ${os}`;
|
||||
return browser ?? os ?? 'Unknown device';
|
||||
}
|
||||
|
||||
export const sessionKey = (sid) => createHash('sha256').update(sid).digest('hex').slice(0, 16);
|
||||
|
||||
// Where the browser says it is, for checking passkeys: APP_URL when set, else the address this request came to.
|
||||
export function relyingParty(req, env = process.env) {
|
||||
if (env.APP_URL) {
|
||||
try { const u = new URL(env.APP_URL); return { rpID: u.hostname, origin: u.origin }; } catch { /* fall through */ }
|
||||
}
|
||||
return { rpID: req.hostname, origin: `${req.protocol}://${req.get('host')}` };
|
||||
}
|
||||
|
||||
/** Called right after a session gets its userId: remembers the device and (if asked) sends a sign-in alert. */
|
||||
export function noteSignIn(req, user, method) {
|
||||
const label = describeDevice(req.get('user-agent') ?? '');
|
||||
req.session.device = { label, ip: req.ip, at: Date.now(), method };
|
||||
if (user.loginAlerts && user.ntfyTopic) {
|
||||
const how = { password: 'password', passkey: 'passkey', mfa: 'password and authenticator code', register: 'a new account' }[method] ?? method;
|
||||
sendNtfy(user, { title: 'New sign-in', body: `${label} signed in to your Bible Study account with ${how}. If that wasn’t you, change your password and sign out other devices.`, tags: 'warning' })
|
||||
.catch((err) => { console.error('Sign-in alert failed:', err.message); logEvent('notify', `Sign-in alert: ${err.message}`, { userId: user.id }); });
|
||||
}
|
||||
}
|
||||
|
||||
const publicPasskey = (p) => ({ id: p.id, name: p.name, createdAt: p.createdAt, lastUsedAt: p.lastUsedAt });
|
||||
|
||||
export function registerDeviceRoutes(app, { requireAuth, userPayload, isLimited, recordFailure, clearFailures, tooMany }) {
|
||||
const fail = (res, status, error) => res.status(status).json({ error });
|
||||
|
||||
// ---- Signing in with a passkey (no email needed: the passkey says who you are) ----
|
||||
app.post('/api/auth/passkey/options', async (req, res) => {
|
||||
try {
|
||||
if (isLimited(`ip:${req.ip}`)) return res.status(429).json(tooMany);
|
||||
const { rpID } = relyingParty(req);
|
||||
const options = await generateAuthenticationOptions({ rpID, userVerification: 'required' });
|
||||
req.session.passkeyChallenge = options.challenge;
|
||||
res.json(options);
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/passkey/options error:', err);
|
||||
fail(res, 500, 'Could not start passkey sign-in.');
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/passkey/verify', async (req, res) => {
|
||||
const keys = [`ip:${req.ip}`];
|
||||
try {
|
||||
if (isLimited(...keys)) return res.status(429).json(tooMany);
|
||||
const challenge = req.session?.passkeyChallenge;
|
||||
const response = req.body;
|
||||
const passkey = typeof response?.id === 'string' ? getPasskey(response.id) : null;
|
||||
const failed = () => { recordFailure(...keys); logEvent('signin', `Passkey sign-in failed from ${req.ip}`); return fail(res, 401, 'That passkey didn’t work. Try again, or use your password.'); };
|
||||
if (!challenge || !passkey) return failed();
|
||||
const { rpID, origin } = relyingParty(req);
|
||||
let result;
|
||||
try {
|
||||
result = await verifyAuthenticationResponse({
|
||||
response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true,
|
||||
credential: { id: passkey.id, publicKey: Buffer.from(passkey.publicKey, 'base64url'), counter: passkey.counter, transports: passkey.transports },
|
||||
});
|
||||
} catch { return failed(); }
|
||||
if (!result.verified) return failed();
|
||||
const user = getUserById(passkey.userId);
|
||||
if (!user) return failed();
|
||||
touchPasskey(passkey.id, result.authenticationInfo.newCounter);
|
||||
clearFailures(...keys);
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return fail(res, 500, 'Could not create session.');
|
||||
req.session.userId = user.id;
|
||||
noteSignIn(req, user, 'passkey');
|
||||
res.json(userPayload(user));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/passkey/verify error:', err);
|
||||
fail(res, 500, 'Could not check that passkey.');
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Managing passkeys (signed in) ----
|
||||
app.get('/api/passkeys', requireAuth, (req, res) => res.json(listPasskeys(req.session.userId).map(publicPasskey)));
|
||||
|
||||
app.post('/api/passkeys/options', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.session.userId);
|
||||
const { rpID } = relyingParty(req);
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: APP_NAME, rpID, userName: user.email, userID: new TextEncoder().encode(user.id), attestationType: 'none',
|
||||
excludeCredentials: listPasskeys(user.id).map((p) => ({ id: p.id, transports: p.transports })),
|
||||
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
|
||||
});
|
||||
req.session.passkeyChallenge = options.challenge;
|
||||
res.json(options);
|
||||
} catch (err) {
|
||||
console.error('POST /api/passkeys/options error:', err);
|
||||
fail(res, 500, 'Could not start adding a passkey.');
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/passkeys', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const challenge = req.session.passkeyChallenge;
|
||||
if (!challenge) return fail(res, 400, 'Start again: that request expired.');
|
||||
const { rpID, origin } = relyingParty(req);
|
||||
let result;
|
||||
try {
|
||||
result = await verifyRegistrationResponse({ response: req.body?.response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true });
|
||||
} catch (err) { return fail(res, 400, `That passkey couldn’t be added (${err.message}).`); }
|
||||
if (!result.verified) return fail(res, 400, 'That passkey couldn’t be added.');
|
||||
delete req.session.passkeyChallenge;
|
||||
const { credential } = result.registrationInfo;
|
||||
if (getPasskey(credential.id)) return fail(res, 409, 'That passkey is already registered.');
|
||||
const name = String(req.body?.name ?? '').trim().slice(0, 60) || describeDevice(req.get('user-agent') ?? '');
|
||||
addPasskey({ id: credential.id, userId: req.session.userId, publicKey: Buffer.from(credential.publicKey).toString('base64url'), counter: credential.counter, transports: credential.transports, name });
|
||||
res.json(listPasskeys(req.session.userId).map(publicPasskey));
|
||||
} catch (err) {
|
||||
console.error('POST /api/passkeys error:', err);
|
||||
fail(res, 500, 'Could not save that passkey.');
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/passkeys/:id', requireAuth, (req, res) => {
|
||||
const name = String(req.body?.name ?? '').trim().slice(0, 60);
|
||||
if (!name) return fail(res, 400, 'Give it a name.');
|
||||
if (!renamePasskey(req.params.id, req.session.userId, name)) return fail(res, 404, 'No such passkey.');
|
||||
res.json(listPasskeys(req.session.userId).map(publicPasskey));
|
||||
});
|
||||
|
||||
app.delete('/api/passkeys/:id', requireAuth, (req, res) => {
|
||||
if (!deletePasskey(req.params.id, req.session.userId)) return fail(res, 404, 'No such passkey.');
|
||||
res.json(listPasskeys(req.session.userId).map(publicPasskey));
|
||||
});
|
||||
|
||||
// ---- Where you are signed in ----
|
||||
app.get('/api/sessions', requireAuth, (req, res) => {
|
||||
const rows = listUserSessions(req.session.userId).map(({ sid, sess, expires }) => ({
|
||||
id: sessionKey(sid), current: sid === req.sessionID,
|
||||
label: sess.device?.label ?? 'Signed in before this list existed', ip: sess.device?.ip ?? null,
|
||||
method: sess.device?.method ?? null, signedInAt: sess.device?.at ?? null,
|
||||
lastSeen: expires - MONTH, // sessions are renewed on each request, so this is when it was last used
|
||||
})).sort((a, b) => Number(b.current) - Number(a.current) || b.lastSeen - a.lastSeen);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
app.delete('/api/sessions/:id', requireAuth, (req, res) => {
|
||||
const found = listUserSessions(req.session.userId).find((s) => sessionKey(s.sid) === req.params.id);
|
||||
if (!found) return fail(res, 404, 'That device is already signed out.');
|
||||
if (found.sid === req.sessionID) return fail(res, 400, 'Use Sign out for this device.');
|
||||
destroySession(found.sid);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/sessions/sign-out-others', requireAuth, (req, res) => {
|
||||
destroyAllSessionsForUser(req.session.userId, req.sessionID);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.put('/api/auth/login-alerts', requireAuth, (req, res) => {
|
||||
const user = getUserById(req.session.userId);
|
||||
const enabled = !!req.body?.enabled;
|
||||
if (enabled && !user.ntfyTopic) return fail(res, 400, 'Set up a notification topic first (under Notifications).');
|
||||
setLoginAlerts(user.id, enabled);
|
||||
res.json({ loginAlerts: enabled });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
import { createHash, generateKeyPairSync, randomBytes, sign } from 'crypto';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
|
||||
const b64 = (b) => Buffer.from(b).toString('base64url');
|
||||
const sha = (b) => createHash('sha256').update(b).digest();
|
||||
|
||||
// A tiny CBOR writer, just enough for the attestation a passkey device would send.
|
||||
const head = (major, n) => (n < 24 ? Buffer.from([(major << 5) | n]) : n < 256 ? Buffer.from([(major << 5) | 24, n]) : Buffer.from([(major << 5) | 25, n >> 8, n & 255]));
|
||||
const cbor = (v) => {
|
||||
if (Buffer.isBuffer(v)) return Buffer.concat([head(2, v.length), v]);
|
||||
if (typeof v === 'string') return Buffer.concat([head(3, Buffer.byteLength(v)), Buffer.from(v)]);
|
||||
if (typeof v === 'number') return v >= 0 ? head(0, v) : head(1, -1 - v);
|
||||
const entries = v instanceof Map ? [...v] : Object.entries(v);
|
||||
return Buffer.concat([head(5, entries.length), ...entries.flatMap(([k, x]) => [cbor(k), cbor(x)])]);
|
||||
};
|
||||
|
||||
// A software "phone": makes passkeys and answers sign-in challenges, like Face ID would.
|
||||
function authenticator(rpID, origin) {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
const jwk = publicKey.export({ format: 'jwk' });
|
||||
const credId = randomBytes(16);
|
||||
let counter = 0;
|
||||
const rpHash = sha(rpID);
|
||||
const client = (type, challenge, o = origin) => Buffer.from(JSON.stringify({ type, challenge, origin: o, crossOrigin: false }));
|
||||
return {
|
||||
id: b64(credId),
|
||||
create(challenge, o) {
|
||||
const cose = cbor(new Map([[1, 2], [3, -7], [-1, 1], [-2, Buffer.from(jwk.x, 'base64url')], [-3, Buffer.from(jwk.y, 'base64url')]]));
|
||||
const len = Buffer.alloc(2); len.writeUInt16BE(credId.length);
|
||||
const cnt = Buffer.alloc(4);
|
||||
const authData = Buffer.concat([rpHash, Buffer.from([0x45]), cnt, Buffer.alloc(16), len, credId, cose]);
|
||||
return { id: b64(credId), rawId: b64(credId), type: 'public-key', clientExtensionResults: {}, response: { clientDataJSON: b64(client('webauthn.create', challenge, o)), attestationObject: b64(cbor({ fmt: 'none', attStmt: {}, authData })), transports: ['internal'] } };
|
||||
},
|
||||
get(challenge, o) {
|
||||
counter += 1;
|
||||
const cnt = Buffer.alloc(4); cnt.writeUInt32BE(counter);
|
||||
const authData = Buffer.concat([rpHash, Buffer.from([0x05]), cnt]);
|
||||
const clientData = client('webauthn.get', challenge, o);
|
||||
const signature = sign('sha256', Buffer.concat([authData, sha(clientData)]), privateKey);
|
||||
return { id: b64(credId), rawId: b64(credId), type: 'public-key', clientExtensionResults: {}, response: { clientDataJSON: b64(clientData), authenticatorData: b64(authData), signature: b64(signature), userHandle: null } };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let dir; let server; let base; let db;
|
||||
beforeEach(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'devices-'));
|
||||
process.env.DATA_DIR = dir; delete process.env.APP_URL;
|
||||
vi.resetModules();
|
||||
db = await import('./db.js'); db.initDb();
|
||||
const { registerDeviceRoutes } = await import('./devices.js');
|
||||
const { requireAuth } = await import('./auth.js');
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(session({ secret: 'test', resave: false, saveUninitialized: false, store: new (await import('./sessionStore.js')).SqliteSessionStore(), cookie: { maxAge: 30 * 24 * 3600 * 1000 } }));
|
||||
const { noteSignIn } = await import('./devices.js');
|
||||
// stand-in for the password sign-in route
|
||||
app.post('/test/login', (req, res) => { const u = db.getUserByEmail(req.body.email); req.session.regenerate(() => { req.session.userId = u.id; noteSignIn(req, u, 'password'); res.json({ ok: true }); }); });
|
||||
registerDeviceRoutes(app, { requireAuth, userPayload: (u) => ({ id: u.id, email: u.email }), isLimited: () => false, recordFailure() {}, clearFailures() {}, tooMany: { error: 'slow down' } });
|
||||
await new Promise((r) => { server = app.listen(0, r); });
|
||||
base = `http://localhost:${server.address().port}`;
|
||||
db.createUser({ id: 'u1', email: 'a@x.test', passwordHash: 'h' });
|
||||
});
|
||||
afterEach(async () => { await new Promise((r) => server.close(r)); rmSync(dir, { recursive: true, force: true }); delete process.env.DATA_DIR; });
|
||||
|
||||
// A browser: keeps its own cookie
|
||||
function browser(ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17 Safari/605.1.15') {
|
||||
let cookie = '';
|
||||
const call = async (method, path, body) => {
|
||||
const res = await fetch(base + path, { method, headers: { 'Content-Type': 'application/json', 'User-Agent': ua, ...(cookie ? { Cookie: cookie } : {}) }, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
const set = res.headers.get('set-cookie');
|
||||
if (set) cookie = set.split(';')[0];
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
};
|
||||
return { call, login: () => call('POST', '/test/login', { email: 'a@x.test' }) };
|
||||
}
|
||||
const originOf = () => base;
|
||||
|
||||
test('add a passkey, then sign in with it from another device', async () => {
|
||||
const phone = authenticator('localhost', originOf());
|
||||
const laptop = browser(); await laptop.login();
|
||||
const { body: opts } = await laptop.call('POST', '/api/passkeys/options');
|
||||
expect(opts.authenticatorSelection.userVerification).toBe('required');
|
||||
const added = await laptop.call('POST', '/api/passkeys', { response: phone.create(opts.challenge), name: 'My phone' });
|
||||
expect(added.status).toBe(200);
|
||||
expect(added.body).toMatchObject([{ id: phone.id, name: 'My phone' }]);
|
||||
|
||||
const other = browser('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1');
|
||||
expect((await other.call('GET', '/api/passkeys')).status).toBe(401); // not signed in yet
|
||||
const { body: ask } = await other.call('POST', '/api/auth/passkey/options');
|
||||
const ok = await other.call('POST', '/api/auth/passkey/verify', phone.get(ask.challenge));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.email).toBe('a@x.test');
|
||||
expect((await other.call('GET', '/api/passkeys')).body[0].lastUsedAt).toBeGreaterThan(0);
|
||||
const devices = (await other.call('GET', '/api/sessions')).body;
|
||||
expect(devices.map((d) => d.label)).toEqual(['Safari on iPhone', 'Safari on macOS']);
|
||||
expect(devices[0]).toMatchObject({ current: true, method: 'passkey' });
|
||||
});
|
||||
|
||||
test('a passkey from the wrong site, an unknown one, or a replay is refused', async () => {
|
||||
const phone = authenticator('localhost', originOf());
|
||||
const laptop = browser(); await laptop.login();
|
||||
const { body: opts } = await laptop.call('POST', '/api/passkeys/options');
|
||||
const bad = await laptop.call('POST', '/api/passkeys', { response: phone.create(opts.challenge, 'https://evil.example') });
|
||||
expect(bad.status).toBe(400);
|
||||
await laptop.call('POST', '/api/passkeys', { response: phone.create(opts.challenge), name: 'ok' });
|
||||
|
||||
const stranger = browser();
|
||||
const { body: ask } = await stranger.call('POST', '/api/auth/passkey/options');
|
||||
expect((await stranger.call('POST', '/api/auth/passkey/verify', authenticator('localhost', originOf()).get(ask.challenge))).status).toBe(401); // never registered
|
||||
expect((await stranger.call('POST', '/api/auth/passkey/verify', phone.get('wrong-challenge'))).status).toBe(401);
|
||||
const good = phone.get(ask.challenge);
|
||||
expect((await stranger.call('POST', '/api/auth/passkey/verify', good)).status).toBe(200);
|
||||
const replay = browser();
|
||||
await replay.call('POST', '/api/auth/passkey/options');
|
||||
expect((await replay.call('POST', '/api/auth/passkey/verify', good)).status).toBe(401); // that answer was for another challenge
|
||||
});
|
||||
|
||||
test('passkeys can be renamed and removed, and only by their owner', async () => {
|
||||
const phone = authenticator('localhost', originOf());
|
||||
const me = browser(); await me.login();
|
||||
const { body: opts } = await me.call('POST', '/api/passkeys/options');
|
||||
await me.call('POST', '/api/passkeys', { response: phone.create(opts.challenge) });
|
||||
expect((await me.call('PATCH', `/api/passkeys/${phone.id}`, { name: 'Pixel' })).body[0].name).toBe('Pixel');
|
||||
expect((await me.call('PATCH', `/api/passkeys/${phone.id}`, { name: ' ' })).status).toBe(400);
|
||||
db.createUser({ id: 'u2', email: 'b@x.test', passwordHash: 'h' });
|
||||
expect(db.deletePasskey(phone.id, 'u2')).toBe(false);
|
||||
expect((await me.call('DELETE', `/api/passkeys/${phone.id}`)).body).toEqual([]);
|
||||
const { body: ask } = await browser().call('POST', '/api/auth/passkey/options');
|
||||
expect(ask.challenge).toBeTruthy();
|
||||
});
|
||||
|
||||
test('devices can be signed out one by one or all at once', async () => {
|
||||
const a = browser(); const b = browser('Mozilla/5.0 (Windows NT 10.0) Chrome/120 Safari/537'); const c = browser('Mozilla/5.0 (Linux; Android 14) Chrome/120 Mobile Safari/537');
|
||||
await a.login(); await b.login(); await c.login();
|
||||
const list = (await a.call('GET', '/api/sessions')).body;
|
||||
expect(list).toHaveLength(3);
|
||||
expect(list[0].current).toBe(true);
|
||||
expect((await a.call('DELETE', `/api/sessions/${list[0].id}`)).status).toBe(400); // not your own
|
||||
const bId = list.find((d) => d.label === 'Chrome on Windows').id;
|
||||
expect((await a.call('DELETE', `/api/sessions/${bId}`)).status).toBe(200);
|
||||
expect((await b.call('GET', '/api/sessions')).status).toBe(401);
|
||||
expect((await a.call('DELETE', `/api/sessions/${bId}`)).status).toBe(404);
|
||||
await a.call('POST', '/api/sessions/sign-out-others');
|
||||
expect((await c.call('GET', '/api/sessions')).status).toBe(401);
|
||||
expect((await a.call('GET', '/api/sessions')).body).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('sign-in alerts need a notification topic', async () => {
|
||||
const a = browser(); await a.login();
|
||||
expect((await a.call('PUT', '/api/auth/login-alerts', { enabled: true })).status).toBe(400);
|
||||
db.setReminderSettings('u1', { ntfyUrl: null, ntfyTopic: 'my-topic', reminderTime: '08:00', reminderEnabled: false, reminderTz: 'UTC' });
|
||||
expect((await a.call('PUT', '/api/auth/login-alerts', { enabled: true })).body).toEqual({ loginAlerts: true });
|
||||
expect(db.getUserById('u1').loginAlerts).toBe(true);
|
||||
});
|
||||
|
||||
test('devices are described in plain words', async () => {
|
||||
const { describeDevice, relyingParty } = await import('./devices.js');
|
||||
expect(describeDevice('Mozilla/5.0 (Windows NT 10.0) Chrome/120 Edg/120')).toBe('Edge on Windows');
|
||||
expect(describeDevice('Mozilla/5.0 (X11; Linux) Firefox/120')).toBe('Firefox on Linux');
|
||||
expect(describeDevice('')).toBe('Unknown device');
|
||||
const req = { hostname: 'study.example', protocol: 'https', get: () => 'study.example:8443' };
|
||||
expect(relyingParty(req, {})).toEqual({ rpID: 'study.example', origin: 'https://study.example:8443' });
|
||||
expect(relyingParty(req, { APP_URL: 'https://study.example/app' })).toEqual({ rpID: 'study.example', origin: 'https://study.example' });
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// What the admin's Health page shows: a short in-memory log of things that went wrong (failed sign-ins, failed
|
||||
// notifications, backups), plus disk use and the snapshot list. The log resets when the server restarts.
|
||||
import { existsSync, statSync, statfsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { dataDir } from './db.js';
|
||||
import { backupDir, listSnapshots } from './backups.js';
|
||||
|
||||
const MAX_EVENTS = 200;
|
||||
const events = [];
|
||||
const started = Date.now();
|
||||
|
||||
// kind: 'signin' | 'notify' | 'backup'. `ok` false marks a failure.
|
||||
export function logEvent(kind, detail, { ok = false, userId = null } = {}) {
|
||||
events.unshift({ at: Date.now(), kind, ok, detail: String(detail).slice(0, 300), userId });
|
||||
if (events.length > MAX_EVENTS) events.length = MAX_EVENTS;
|
||||
}
|
||||
|
||||
export const recentEvents = (kind, limit = 50) => events.filter((e) => !kind || e.kind === kind).slice(0, limit);
|
||||
export function clearEvents() { events.length = 0; }
|
||||
|
||||
const size = (path) => { try { return statSync(path).size; } catch { return 0; } };
|
||||
|
||||
export function diskUse() {
|
||||
const dir = dataDir();
|
||||
const db = size(join(dir, 'projects.db')) + size(join(dir, 'projects.db-wal'));
|
||||
const backups = listSnapshots(backupDir()).reduce((n, s) => n + s.bytes, 0);
|
||||
let free = null; let total = null;
|
||||
try { const s = statfsSync(existsSync(dir) ? dir : '.'); free = s.bavail * s.bsize; total = s.blocks * s.bsize; } catch { /* not supported here */ }
|
||||
return { db, backups, free, total };
|
||||
}
|
||||
|
||||
export function backupStatus() {
|
||||
const snapshots = listSnapshots(backupDir());
|
||||
return { enabled: process.env.BACKUP_DISABLED !== '1', keep: Number(process.env.BACKUP_KEEP) || 7, snapshots, latest: snapshots[0] ?? null };
|
||||
}
|
||||
|
||||
export const uptimeSeconds = () => Math.round((Date.now() - started) / 1000);
|
||||
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
let dir;
|
||||
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'health-')); process.env.DATA_DIR = dir; vi.resetModules(); });
|
||||
afterEach(() => { rmSync(dir, { recursive: true, force: true }); delete process.env.DATA_DIR; });
|
||||
|
||||
const load = async () => {
|
||||
const db = await import('./db.js'); db.initDb();
|
||||
return { db, backups: await import('./backups.js'), health: await import('./health.js') };
|
||||
};
|
||||
|
||||
test('the event log keeps the newest first, filters by kind and is capped', async () => {
|
||||
const { health } = await load();
|
||||
health.logEvent('signin', 'a'); health.logEvent('notify', 'b'); health.logEvent('signin', 'c');
|
||||
expect(health.recentEvents('signin').map((e) => e.detail)).toEqual(['c', 'a']);
|
||||
for (let i = 0; i < 300; i += 1) health.logEvent('signin', String(i));
|
||||
expect(health.recentEvents(null, 1000)).toHaveLength(200);
|
||||
});
|
||||
|
||||
test('a snapshot can be restored, and what was there is kept as a safety copy', async () => {
|
||||
const { db, backups } = await load();
|
||||
db.createUser({ id: 'u1', email: 'a@x.test', passwordHash: 'h' });
|
||||
await backups.runBackup(new Date('2026-10-01T10:00:00Z'));
|
||||
db.createUser({ id: 'u2', email: 'b@x.test', passwordHash: 'h' });
|
||||
expect(db.countUsers()).toBe(2);
|
||||
const [snap] = backups.listSnapshots();
|
||||
const result = await backups.restoreSnapshot(snap.name, new Date('2026-10-02T10:00:00Z'));
|
||||
expect(db.countUsers()).toBe(1); // back to when the snapshot was taken
|
||||
expect(backups.listSnapshots().map((s) => s.name)).toContain(result.safetyCopy.split('/').pop());
|
||||
});
|
||||
|
||||
test('only real snapshot names resolve', async () => {
|
||||
const { backups } = await load();
|
||||
expect(backups.snapshotPath('../projects.db')).toBeNull();
|
||||
expect(backups.snapshotPath('projects-2026-01-01-00-00.db')).toBeNull(); // valid name, but no such file
|
||||
await expect(backups.restoreSnapshot('nope')).rejects.toThrow('No such snapshot');
|
||||
});
|
||||
|
||||
test('disk use reports the database and snapshot sizes', async () => {
|
||||
const { backups, health } = await load();
|
||||
await backups.runBackup();
|
||||
const use = health.diskUse();
|
||||
expect(use.db).toBeGreaterThan(0);
|
||||
expect(use.backups).toBeGreaterThan(0);
|
||||
expect(health.backupStatus().latest).not.toBeNull();
|
||||
});
|
||||
@@ -1,13 +1,76 @@
|
||||
import express from 'express';
|
||||
import compression from 'compression';
|
||||
import session from 'express-session';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { initDb, getAllProjects, getProject, upsertProject, deleteProject } from './db.js';
|
||||
import { initDb, dataDir } from './db.js';
|
||||
import { SqliteSessionStore } from './sessionStore.js';
|
||||
import { startReminderScheduler } from './reminders.js';
|
||||
import { startBackupScheduler } from './backups.js';
|
||||
import { registerBibleRoutes } from './bibleApi.js';
|
||||
import { registerIqBibleRoutes } from './iqbible.js';
|
||||
import { registerDeviceRoutes } from './devices.js';
|
||||
import { requireAuth } from './auth.js';
|
||||
import { userPayload } from './userPayload.js';
|
||||
import { isLimited, recordFailure, clearFailures, TOO_MANY } from './limiter.js';
|
||||
import { registerAuthRoutes } from './routes/auth.js';
|
||||
import { registerAccountRoutes } from './routes/account.js';
|
||||
import { registerProjectRoutes } from './routes/projects.js';
|
||||
import { registerReaderRoutes } from './routes/reader.js';
|
||||
import { registerSharingRoutes } from './routes/sharing.js';
|
||||
import { registerAdminRoutes } from './routes/admin.js';
|
||||
|
||||
// The server's setup: sessions and middleware, then each area registers its own routes (server/routes/*.js,
|
||||
// plus the Bible text, IQ Bible and passkey modules), then the built app is served and the server starts.
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
// Session cookies are signed with SESSION_SECRET. When it isn't set (setup.sh doesn't set one), a random secret
|
||||
// is made once and kept in DATA_DIR, so it survives restarts and nobody has to configure anything.
|
||||
function sessionSecret() {
|
||||
if (process.env.SESSION_SECRET) return process.env.SESSION_SECRET;
|
||||
if (!isProd) return 'dev-only-secret-change-me';
|
||||
const file = join(dataDir(), '.session-secret');
|
||||
try {
|
||||
if (existsSync(file)) return readFileSync(file, 'utf8').trim();
|
||||
mkdirSync(dataDir(), { recursive: true });
|
||||
const made = randomBytes(48).toString('hex');
|
||||
writeFileSync(file, made, { mode: 0o600 });
|
||||
console.log(`SESSION_SECRET is not set; generated one and saved it to ${file}.`);
|
||||
return made;
|
||||
} catch (err) {
|
||||
console.error('Could not read or save a session secret; set SESSION_SECRET instead.', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Trust the reverse proxy (needed for secure cookies to work behind nginx/etc).
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
|
||||
// Gzip responses — the interlinear JSON files shrink ~10x, and JS/CSS/API payloads too.
|
||||
app.use(compression());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(session({
|
||||
store: new SqliteSessionStore(),
|
||||
secret: sessionSecret(),
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
rolling: true,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
// 'auto' = Secure when the request arrived over HTTPS (via the proxy's
|
||||
// X-Forwarded-Proto). A hard `true` silently breaks login if the proxy
|
||||
// doesn't send that header, or when the app is opened over plain http.
|
||||
secure: isProd ? 'auto' : false,
|
||||
sameSite: 'lax',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
},
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health check
|
||||
@@ -16,76 +79,34 @@ app.get('/api/health', (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/projects — list all project summaries (no full data)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/projects', (_req, res) => {
|
||||
try {
|
||||
const projects = getAllProjects();
|
||||
res.json(projects);
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects error:', err);
|
||||
res.status(500).json({ error: 'Failed to list projects.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/projects/:id — fetch a single full project
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/projects/:id', (req, res) => {
|
||||
try {
|
||||
const project = getProject(req.params.id);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json(project);
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to load project.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /api/projects/:id — create or update a project
|
||||
// ---------------------------------------------------------------------------
|
||||
app.put('/api/projects/:id', (req, res) => {
|
||||
try {
|
||||
const body = req.body;
|
||||
if (!body || typeof body !== 'object') {
|
||||
return res.status(400).json({ error: 'Invalid JSON body.' });
|
||||
}
|
||||
if (!body.id || !body.title || !Array.isArray(body.chapters)) {
|
||||
return res.status(400).json({ error: 'Missing required fields: id, title, chapters.' });
|
||||
}
|
||||
if (body.id !== req.params.id) {
|
||||
return res.status(400).json({ error: 'URL id does not match body id.' });
|
||||
}
|
||||
const saved = upsertProject(body);
|
||||
res.json(saved);
|
||||
} catch (err) {
|
||||
console.error('PUT /api/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to save project.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/projects/:id — remove a project
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete('/api/projects/:id', (req, res) => {
|
||||
try {
|
||||
deleteProject(req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete project.' });
|
||||
}
|
||||
});
|
||||
registerAuthRoutes(app);
|
||||
registerAccountRoutes(app);
|
||||
registerProjectRoutes(app);
|
||||
registerReaderRoutes(app);
|
||||
registerBibleRoutes(app, requireAuth);
|
||||
registerIqBibleRoutes(app, requireAuth);
|
||||
registerDeviceRoutes(app, { requireAuth, userPayload, isLimited, recordFailure, clearFailures, tooMany: TOO_MANY });
|
||||
registerSharingRoutes(app);
|
||||
registerAdminRoutes(app);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serve Vite production build (when NODE_ENV=production)
|
||||
// ---------------------------------------------------------------------------
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (isProd) {
|
||||
const distPath = join(__dirname, '..', 'dist');
|
||||
app.use(express.static(distPath));
|
||||
// Vite's /assets files have content hashes in their names, so they can be
|
||||
// cached forever. Other static files (interlinear JSON, icons) cache for a
|
||||
// day; index.html and the service worker always revalidate so deploys show up.
|
||||
const noCache = (res) => res.setHeader('Cache-Control', 'no-cache');
|
||||
app.use('/assets', express.static(join(distPath, 'assets'), { immutable: true, maxAge: '1y' }));
|
||||
app.use(express.static(distPath, {
|
||||
maxAge: '1d',
|
||||
setHeaders: (res, path) => {
|
||||
if (path.endsWith('index.html') || path.endsWith('sw.js')) noCache(res);
|
||||
},
|
||||
}));
|
||||
app.get('*', (_req, res) => {
|
||||
noCache(res);
|
||||
res.sendFile(join(distPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
@@ -94,9 +115,11 @@ if (process.env.NODE_ENV === 'production') {
|
||||
// Start
|
||||
// ---------------------------------------------------------------------------
|
||||
initDb();
|
||||
startReminderScheduler();
|
||||
startBackupScheduler();
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Bible Study API running on http://localhost:${PORT}`);
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (isProd) {
|
||||
console.log('Serving Vite build from /dist');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// Proxy for the IQ Bible API (https://api.iqbible.com): commentaries, lexicons, original-language text,
|
||||
// places, topics, devotionals, narrations and more. The API key stays on the server (IQBIBLE_API_KEY,
|
||||
// from the environment or .env); the browser only talks to /api/iq/... and only to the routes listed here.
|
||||
// Only verse/chapter text is cached (up to 24h, as the API's terms require); see isTextPath.
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const envPath = join(__dirname, '..', '.env');
|
||||
if (existsSync(envPath)) {
|
||||
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
|
||||
}
|
||||
}
|
||||
|
||||
const BASE = () => (process.env.IQBIBLE_API_BASE || 'https://api.iqbible.com/api/v2').replace(/\/+$/, '');
|
||||
const S = '[\\w.~%-]+'; // one path segment
|
||||
const route = (pattern, opts = {}) => ({ re: new RegExp(`^${pattern.replace(/@/g, S)}/?$`), ...opts });
|
||||
|
||||
// Every upstream route the app may reach. Anything else is a 404 here, whatever the browser asks for.
|
||||
export const ROUTES = [
|
||||
route('/commentaries'), route('/commentaries/@/@/@'), route('/commentaries/@/@/@/@'),
|
||||
route('/books/@/info'), route('/books/@/chapters/@/info'), route('/books/@/commentaries'), route('/books/abbreviations'),
|
||||
route('/lexicon/sources'), route('/lexicon/crosswalk/@'),
|
||||
route('/lexicon/(?:greek|hebrew)/(?:strongs|bdb|lsj|abbott-smith)/@'), route('/lexicon/greek/lsj/word/@'),
|
||||
route('/original-language/@/@'), route('/original-language/@/@/@'), route('/morphology/@'),
|
||||
route('/geo/@/@'), route('/geo/@/@/@'), route('/geo/places/@'),
|
||||
route('/topics'), route('/topics/@'), route('/topics/@/@'), route('/topics/@/@/@'),
|
||||
route('/genealogies/@'), route('/bible-characters/@'), route('/bible-people/@'), route('/bible-people/@/@'),
|
||||
route('/harmony'), route('/harmony/@'), route('/textual-variants/@/@'), route('/textual-variants/@/@/@'),
|
||||
route('/chronology/for/@/@'), route('/reference/weights-measures'),
|
||||
route('/cross-references/graph'), route('/cross-references/@/@'), route('/cross-references/@/@/@'),
|
||||
route('/dictionaries'), route('/dictionaries/@'),
|
||||
route('/devotionals/today'), route('/devotionals/@/@'),
|
||||
route('/bibles'), route('/bibles/languages'), route('/bibles/parallel'), route('/bibles/@'), route('/bibles/@/@'), route('/bibles/@/verse-of-the-day'), route('/bibles/@/reading-plan'),
|
||||
route('/bibles/@/@/@/@'), route('/bibles/@/@/@'),
|
||||
route('/audio'), route('/audio/@/@/@', { cache: false }),
|
||||
route('/ai/context', { heavy: true }), route('/bibles/@/search', { heavy: true }), route('/parse/citations'),
|
||||
];
|
||||
|
||||
// Verse and chapter text is the one thing the API's terms let (and require) a client to cache, for up to 24h.
|
||||
// Everything else caps at 1 hour and needs written permission, so it is only cached when IQBIBLE_CACHE_HOURS
|
||||
// is set (do that only once permission is on file).
|
||||
const TEXT_PATH = /^\/bibles\/[\w]+\/(?!random\/|search|reading-plan|verse-of-the-day|verses|books|word-count)[\w.~%-]+\/\d+(?:\/[\d-]+)?$/;
|
||||
export const isTextPath = (p) => TEXT_PATH.test(p.split('?')[0]);
|
||||
|
||||
const QUERY_KEY = /^[\w-]{1,40}$/;
|
||||
|
||||
// Maps an incoming sub-path + query onto an upstream URL, or null when it isn't an allowed route.
|
||||
// The API key can never be supplied by the browser (apikey is dropped).
|
||||
export function resolveIqPath(path, query = {}) {
|
||||
const clean = `/${String(path ?? '').replace(/^\/+/, '')}`;
|
||||
if (clean.includes('..') || clean.includes('//') || clean.length > 300) return null;
|
||||
const hit = ROUTES.find((r) => r.re.test(clean));
|
||||
if (!hit) return null;
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(query).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
if (!QUERY_KEY.test(k) || k.toLowerCase() === 'apikey' || typeof v !== 'string' || v.length > 200) continue;
|
||||
qs.append(k, v);
|
||||
}
|
||||
const q = qs.toString();
|
||||
const key = `${clean}${q ? `?${q}` : ''}`;
|
||||
return { key, cache: hit.cache !== false, heavy: !!hit.heavy, text: isTextPath(clean) };
|
||||
}
|
||||
|
||||
// ---- caching, bounded by the API's own licensing summary ----
|
||||
let policy = null; // { textMs, otherMs }
|
||||
let policyAt = 0;
|
||||
async function cacheTtl(headers, isText) {
|
||||
if (!policy || Date.now() - policyAt > 6 * 3600_000) {
|
||||
try {
|
||||
const res = await fetch(`${BASE()}/licensing`, { headers, signal: AbortSignal.timeout(8000) });
|
||||
const s = (await res.json())?.summary;
|
||||
const allowed = s?.caching_allowed !== false;
|
||||
const textHours = allowed ? Math.min(Number(s?.caching_verse_chapter_hours) || 24, 24) : 0;
|
||||
const optIn = Number(process.env.IQBIBLE_CACHE_HOURS) || 0; // only with the API owner's written permission
|
||||
const otherHours = allowed ? Math.min(optIn, Number(s?.caching_max_hours) || 1) : 0;
|
||||
policy = { textMs: textHours * 3600_000, otherMs: otherHours * 3600_000 };
|
||||
} catch {
|
||||
policy = { textMs: 0, otherMs: 0 };
|
||||
}
|
||||
policyAt = Date.now();
|
||||
}
|
||||
return isText ? policy.textMs : policy.otherMs;
|
||||
}
|
||||
|
||||
const cache = new Map(); // key -> { at, status, type, body }
|
||||
const CACHE_MAX = 1500;
|
||||
|
||||
// Requests for the same thing that arrive while one is already on its way share that one call. Nothing is kept
|
||||
// afterwards (that would be caching), so this spends less of the allowance without touching the API's terms.
|
||||
const inflight = new Map(); // upstream path -> Promise of { status, type, body, retryAfter }
|
||||
function fetchOnce(key, headers) {
|
||||
let p = inflight.get(key);
|
||||
if (!p) {
|
||||
p = (async () => {
|
||||
const upstream = await fetch(`${BASE()}${key}`, { headers, signal: AbortSignal.timeout(20_000) });
|
||||
return {
|
||||
status: upstream.status, ok: upstream.ok, retryAfter: upstream.headers.get('retry-after'),
|
||||
type: upstream.headers.get('content-type') || 'application/json', body: Buffer.from(await upstream.arrayBuffer()),
|
||||
};
|
||||
})().finally(() => inflight.delete(key));
|
||||
inflight.set(key, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
const hits = new Map(); // `${userId}:${bucket}` -> { n, resetAt }
|
||||
function overLimit(userId, heavy) {
|
||||
const bucket = heavy ? 'h' : 'n';
|
||||
const max = heavy ? 10 : 120; // per minute, per signed-in user (the API budget is shared by everyone)
|
||||
const k = `${userId}:${bucket}`;
|
||||
const now = Date.now();
|
||||
const e = hits.get(k);
|
||||
if (!e || now > e.resetAt) { hits.set(k, { n: 1, resetAt: now + 60_000 }); return false; }
|
||||
e.n += 1;
|
||||
return e.n > max;
|
||||
}
|
||||
|
||||
export function registerIqBibleRoutes(app, requireAuth) {
|
||||
const apiKey = () => process.env.IQBIBLE_API_KEY;
|
||||
|
||||
app.get('/api/iq/status', requireAuth, (_req, res) => res.json({ available: Boolean(apiKey()) }));
|
||||
|
||||
app.get('/api/iq/*', requireAuth, async (req, res) => {
|
||||
const target = resolveIqPath(req.params[0], req.query);
|
||||
if (!target) return res.status(404).json({ error: 'Unknown IQ Bible route.' });
|
||||
if (!apiKey()) return res.status(503).json({ error: 'IQBIBLE_API_KEY is not configured on the server.' });
|
||||
if (overLimit(req.session.userId, target.heavy)) return res.status(429).json({ error: 'Slow down a little and try again in a minute.' });
|
||||
|
||||
const headers = { 'X-IQBible-API-Key': apiKey(), Accept: '*/*' };
|
||||
const hit = target.cache && (target.text || process.env.IQBIBLE_CACHE_HOURS) ? cache.get(target.key) : null;
|
||||
if (hit && Date.now() - hit.at < hit.ttl) {
|
||||
res.status(hit.status).type(hit.type).send(hit.body);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const upstream = await fetchOnce(target.key, headers);
|
||||
const { body, type } = upstream;
|
||||
if (upstream.status === 401) return res.status(502).json({ error: 'The IQ Bible key was rejected. Check IQBIBLE_API_KEY.' });
|
||||
if (upstream.status === 429) {
|
||||
const retry = upstream.retryAfter;
|
||||
if (retry) res.set('Retry-After', retry);
|
||||
return res.status(429).json({ error: 'The IQ Bible allowance is used up for now. Try again later.' });
|
||||
}
|
||||
if (upstream.ok && target.cache && (target.text || process.env.IQBIBLE_CACHE_HOURS)) {
|
||||
const ttl = await cacheTtl(headers, target.text);
|
||||
if (ttl > 0) {
|
||||
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value);
|
||||
cache.set(target.key, { at: Date.now(), ttl, status: upstream.status, type, body });
|
||||
}
|
||||
}
|
||||
res.status(upstream.status).type(type).send(body);
|
||||
} catch {
|
||||
res.status(502).json({ error: 'Could not reach the IQ Bible service.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect, vi } from 'vitest';
|
||||
import { resolveIqPath } from './iqbible.js';
|
||||
|
||||
test('only listed IQ Bible routes are reachable', () => {
|
||||
expect(resolveIqPath('commentaries/gill/JHN/3/16')?.key).toBe('/commentaries/gill/JHN/3/16');
|
||||
expect(resolveIqPath('/geo/JHN/3')?.cache).toBe(true);
|
||||
expect(resolveIqPath('/me')).toBeNull();
|
||||
expect(resolveIqPath('/licensing')).toBeNull();
|
||||
expect(resolveIqPath('/commentaries/gill/../me')).toBeNull();
|
||||
expect(resolveIqPath('/lexicon/greek/strongs/G26')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('lexicon sources and audio routes', () => {
|
||||
expect(resolveIqPath('/lexicon/greek/lsj/G0026')).not.toBeNull();
|
||||
expect(resolveIqPath('/lexicon/hebrew/bdb/BDB1005')).not.toBeNull();
|
||||
expect(resolveIqPath('/audio/eng_kjv/JHN/3')?.cache).toBe(false);
|
||||
expect(resolveIqPath('/ai/context', { ref: 'JHN.3.16' })?.heavy).toBe(true);
|
||||
});
|
||||
|
||||
test('the browser cannot send its own key and the query is normalised', () => {
|
||||
const r = resolveIqPath('/bibles/eng_kjv/reading-plan', { days: '30', apikey: 'x', 'bad key': '1', format: 'ical' });
|
||||
expect(r.key).toBe('/bibles/eng_kjv/reading-plan?days=30&format=ical');
|
||||
});
|
||||
|
||||
test('only verse and chapter text counts as cacheable text', async () => {
|
||||
const { isTextPath } = await import('./iqbible.js');
|
||||
expect(isTextPath('/bibles/eng_kjv/JHN/3')).toBe(true);
|
||||
expect(isTextPath('/bibles/eng_kjv/JHN/3/16')).toBe(true);
|
||||
expect(isTextPath('/bibles/eng_kjv/random/verse')).toBe(false);
|
||||
expect(isTextPath('/bibles/eng_kjv/verse-of-the-day')).toBe(false);
|
||||
expect(isTextPath('/commentaries/gill/JHN/3')).toBe(false);
|
||||
expect(resolveIqPath('/bibles/parallel', { v: 'eng_kjv,spa_rv' })).not.toBeNull();
|
||||
});
|
||||
|
||||
test('identical requests made at the same moment share one upstream call, and nothing is kept afterwards', async () => {
|
||||
vi.resetModules();
|
||||
process.env.IQBIBLE_API_KEY = 'k';
|
||||
const realFetch = globalThis.fetch;
|
||||
let upstreamCalls = 0;
|
||||
vi.stubGlobal('fetch', vi.fn(async (url, opts) => {
|
||||
if (String(url).startsWith('http://localhost')) return realFetch(url, opts); // the test's own requests
|
||||
upstreamCalls += 1;
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
return new Response(JSON.stringify({ data: 1 }), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}));
|
||||
const { registerIqBibleRoutes } = await import('./iqbible.js');
|
||||
const express = (await import('express')).default;
|
||||
const app = express();
|
||||
app.use((req, _res, next) => { req.session = { userId: 'u1' }; next(); });
|
||||
registerIqBibleRoutes(app, (_req, _res, next) => next());
|
||||
const srv = await new Promise((resolve) => { const s = app.listen(0, () => resolve(s)); });
|
||||
const url = `http://localhost:${srv.address().port}/api/iq/devotionals/today`;
|
||||
try {
|
||||
const together = await Promise.all([realFetch(url), realFetch(url), realFetch(url)]);
|
||||
expect(together.map((r) => r.status)).toEqual([200, 200, 200]);
|
||||
expect(upstreamCalls).toBe(1);
|
||||
await realFetch(url); // afterwards: not remembered, so it asks again
|
||||
expect(upstreamCalls).toBe(2);
|
||||
} finally { srv.close(); vi.unstubAllGlobals(); delete process.env.IQBIBLE_API_KEY; }
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
// In-memory limiter for sign-in style endpoints: 10 FAILED attempts per 15 min, tracked per IP and per
|
||||
// account (key). Successful sign-ins don't count, so a shared IP doesn't lock everyone out.
|
||||
const _failures = new Map();
|
||||
const FAIL_WINDOW = 15 * 60 * 1000;
|
||||
const FAIL_MAX = 10;
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, e] of _failures) if (now > e.resetAt) _failures.delete(k);
|
||||
}, 60 * 60 * 1000).unref();
|
||||
export function isLimited(...keys) {
|
||||
const now = Date.now();
|
||||
return keys.some((k) => { const e = _failures.get(k); return e && now <= e.resetAt && e.count >= FAIL_MAX; });
|
||||
}
|
||||
export function recordFailure(...keys) {
|
||||
const now = Date.now();
|
||||
for (const k of keys) {
|
||||
const e = _failures.get(k);
|
||||
if (!e || now > e.resetAt) _failures.set(k, { count: 1, resetAt: now + FAIL_WINDOW });
|
||||
else e.count += 1;
|
||||
}
|
||||
}
|
||||
export function clearFailures(...keys) { keys.forEach((k) => _failures.delete(k)); }
|
||||
export const TOO_MANY = { error: 'Too many attempts. Please wait 15 minutes.' };
|
||||
@@ -0,0 +1,59 @@
|
||||
// What a read-only outline link shows: the outlines of a study (the whole-study one and each chunk's)
|
||||
// and nothing else — no observations, interpretations, applications, scripts or word notes. The
|
||||
// outline data comes from the browser, so it is rebuilt field by field instead of passed through.
|
||||
const MAX_NODES = 2000;
|
||||
const MAX_DEPTH = 8;
|
||||
const KINDS = new Set(['', 'illustration', 'application', 'quote', 'question', 'transition']);
|
||||
|
||||
const str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
|
||||
|
||||
function cleanTree(nodes, depth, counter) {
|
||||
if (!Array.isArray(nodes) || depth > MAX_DEPTH) return [];
|
||||
const out = [];
|
||||
for (const n of nodes) {
|
||||
if (!n || typeof n !== 'object' || counter.n >= MAX_NODES) continue;
|
||||
counter.n += 1;
|
||||
const minutes = Number.isFinite(n.minutes) && n.minutes >= 0 && n.minutes <= 600 ? n.minutes : null;
|
||||
out.push({
|
||||
id: str(n.id, 64) || `n${counter.n}`,
|
||||
text: str(n.text, 2000),
|
||||
ref: str(n.ref, 200),
|
||||
kind: KINDS.has(n.kind) ? n.kind : '',
|
||||
minutes,
|
||||
note: str(n.note, 8000),
|
||||
scripture: n.scripture && typeof n.scripture.text === 'string'
|
||||
? { ref: str(n.scripture.ref, 200), text: str(n.scripture.text, 6000), translation: str(n.scripture.translation, 12) }
|
||||
: null,
|
||||
attachments: (Array.isArray(n.attachments) ? n.attachments : []).slice(0, 50)
|
||||
.filter((a) => a && typeof a === 'object')
|
||||
.map((a) => ({ id: str(a.id, 100), type: a.type === 'word' ? 'word' : 'xref', label: str(a.label, 200), ...(a.detail ? { detail: str(a.detail, 500) } : {}) })),
|
||||
children: cleanTree(n.children, depth + 1, counter),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const chunkLabel = (chapters, chapterIndex, chunk) => {
|
||||
const ch = chapters[chapterIndex];
|
||||
const next = chapters[chapterIndex + 1];
|
||||
if (Number.isInteger(chunk.spilloverEndVerse) && next && next.bookAbbrev === ch.bookAbbrev) {
|
||||
return `${ch.book} ${ch.chapter}:${chunk.startVerse}–${next.chapter}:${chunk.spilloverEndVerse}`;
|
||||
}
|
||||
return chunk.startVerse === chunk.endVerse ? `${ch.book} ${ch.chapter}:${chunk.startVerse}` : `${ch.book} ${ch.chapter}:${chunk.startVerse}–${chunk.endVerse}`;
|
||||
};
|
||||
|
||||
// -> { title, translation, outlines: [{ scope, label, tree }] } with only the outlines that have points
|
||||
export function buildOutlinePayload(project) {
|
||||
const counter = { n: 0 };
|
||||
const outlines = [];
|
||||
const whole = cleanTree(project?.outline, 0, counter);
|
||||
if (whole.length > 0) outlines.push({ scope: 'study', label: 'Whole study', tree: whole });
|
||||
const chapters = Array.isArray(project?.chapters) ? project.chapters : [];
|
||||
chapters.forEach((ch, chapterIndex) => {
|
||||
for (const chunk of Array.isArray(ch?.chunks) ? ch.chunks : []) {
|
||||
const tree = cleanTree(chunk?.outline, 0, counter);
|
||||
if (tree.length > 0) outlines.push({ scope: str(chunk.id, 64), label: str(chunkLabel(chapters, chapterIndex, chunk), 200), tree });
|
||||
}
|
||||
});
|
||||
return { title: str(project?.title, 500), translation: str(project?.translation, 12) || 'BSB', outlines };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { buildOutlinePayload } from './outlineShare.js';
|
||||
|
||||
const node = (id, extra = {}) => ({ id, text: id, ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [], ...extra });
|
||||
const project = {
|
||||
title: 'Titus', translation: 'BSB',
|
||||
outline: [node('a', { minutes: 5, kind: 'illustration', children: [node('a1')] })],
|
||||
chapters: [{
|
||||
book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'secret verse text' }],
|
||||
chunks: [
|
||||
{ id: 'c1', startVerse: 1, endVerse: 4, observation: 'PRIVATE OBSERVATION', finalScript: 'PRIVATE SCRIPT', greekWords: [{ lexeme: 'x' }], outline: [node('b')] },
|
||||
{ id: 'c2', startVerse: 5, endVerse: 5, observation: 'more private', outline: [] },
|
||||
],
|
||||
}],
|
||||
};
|
||||
|
||||
test('shares the outlines only, with each chunk labelled by its passage', () => {
|
||||
const p = buildOutlinePayload(project);
|
||||
expect(p.title).toBe('Titus');
|
||||
expect(p.outlines.map((o) => [o.scope, o.label])).toEqual([['study', 'Whole study'], ['c1', 'Titus 1:1–4']]);
|
||||
expect(p.outlines[0].tree[0]).toMatchObject({ text: 'a', minutes: 5, kind: 'illustration' });
|
||||
expect(p.outlines[0].tree[0].children[0].text).toBe('a1');
|
||||
});
|
||||
|
||||
test('nothing from the rest of the study leaks into what is shared', () => {
|
||||
const json = JSON.stringify(buildOutlinePayload(project));
|
||||
for (const secret of ['PRIVATE OBSERVATION', 'PRIVATE SCRIPT', 'secret verse text', 'more private', 'greekWords']) expect(json).not.toContain(secret);
|
||||
});
|
||||
|
||||
test('unknown fields on a point are dropped, and bad values are cleaned', () => {
|
||||
const p = buildOutlinePayload({ title: 'T', outline: [{ id: 'x', text: 'hi', evil: '<script>', kind: 'bogus', minutes: -4, scripture: { text: 5 }, attachments: [{ type: 'word', label: 'L' }, null], children: 'nope' }], chapters: [] });
|
||||
expect(p.outlines[0].tree[0]).toEqual({
|
||||
id: 'x', text: 'hi', ref: '', kind: '', minutes: null, note: '', scripture: null,
|
||||
attachments: [{ id: '', type: 'word', label: 'L' }], children: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('a study with no outlines shares none, and a huge outline is capped', () => {
|
||||
expect(buildOutlinePayload({ title: 'T', chapters: [] }).outlines).toEqual([]);
|
||||
const big = { title: 'T', outline: Array.from({ length: 3000 }, (_, i) => node(`n${i}`)), chapters: [] };
|
||||
expect(buildOutlinePayload(big).outlines[0].tree).toHaveLength(2000);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// The prayer journal, kept per account. Each prayer carries its own `updatedAt`, so two devices that each add or
|
||||
// change prayers merge cleanly: the newer version of each prayer wins, the days prayed are combined, and a
|
||||
// deleted prayer stays as a small marker for a while so another device can't bring it back.
|
||||
const ID = /^[\w-]{1,64}$/;
|
||||
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const BOOK = /^[1-3A-Z]{3}$/;
|
||||
const MAX_PRAYERS = 2000;
|
||||
const MAX_DAYS = 400;
|
||||
const KEEP_DELETED_MS = 90 * 86_400_000;
|
||||
|
||||
const text = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
|
||||
const int = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
|
||||
const time = (v, fallback) => (Number.isFinite(v) && v > 0 ? v : fallback);
|
||||
|
||||
// One prayer from the browser, rebuilt field by field; null when it isn't one.
|
||||
export function cleanPrayer(raw, now = Date.now()) {
|
||||
if (!raw || typeof raw !== 'object' || !ID.test(String(raw.id ?? ''))) return null;
|
||||
const updatedAt = Math.min(time(raw.updatedAt, 0), now + 86_400_000);
|
||||
if (raw.deleted === true) return { id: raw.id, deleted: true, updatedAt };
|
||||
const body = text(raw.text, 2000).trim();
|
||||
if (!body) return null;
|
||||
const verses = (Array.isArray(raw.verses) ? raw.verses : []).slice(0, 8)
|
||||
.filter((v) => v && BOOK.test(String(v.book)) && int(v.chapter, 1, 200) && (v.verse == null || int(v.verse, 1, 200)))
|
||||
.map((v) => ({ book: v.book, chapter: v.chapter, verse: v.verse ?? null, label: text(v.label, 60) }));
|
||||
const prayedOn = [...new Set((Array.isArray(raw.prayedOn) ? raw.prayedOn : []).filter((d) => typeof d === 'string' && DAY.test(d)))].sort().slice(-MAX_DAYS);
|
||||
return {
|
||||
id: raw.id, text: body, forWhom: text(raw.forWhom, 80).trim(), verses, prayedOn,
|
||||
createdAt: time(raw.createdAt, updatedAt), updatedAt,
|
||||
answeredAt: Number.isFinite(raw.answeredAt) && raw.answeredAt > 0 ? raw.answeredAt : null,
|
||||
answerNote: text(raw.answerNote, 1000).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function cleanPrayers(list, now = Date.now()) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const raw of (Array.isArray(list) ? list : []).slice(0, MAX_PRAYERS)) {
|
||||
const p = cleanPrayer(raw, now);
|
||||
if (!p || seen.has(p.id)) continue;
|
||||
seen.add(p.id);
|
||||
out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Two copies of the journal -> one. The newer version of each prayer wins; the days prayed are the union of both.
|
||||
export function mergePrayers(a, b, now = Date.now()) {
|
||||
const byId = new Map();
|
||||
for (const p of [...cleanPrayers(a, now), ...cleanPrayers(b, now)]) {
|
||||
const have = byId.get(p.id);
|
||||
if (!have) { byId.set(p.id, p); continue; }
|
||||
const [newer, older] = p.updatedAt >= have.updatedAt ? [p, have] : [have, p];
|
||||
byId.set(p.id, newer.deleted ? newer : { ...newer, prayedOn: [...new Set([...(newer.prayedOn ?? []), ...(older.deleted ? [] : older.prayedOn ?? [])])].sort().slice(-MAX_DAYS) });
|
||||
}
|
||||
return [...byId.values()]
|
||||
.filter((p) => !(p.deleted && now - p.updatedAt > KEEP_DELETED_MS)) // old markers are forgotten
|
||||
.sort((x, y) => (y.updatedAt ?? 0) - (x.updatedAt ?? 0));
|
||||
}
|
||||
|
||||
export const openPrayers = (list) => list.filter((p) => !p.deleted && !p.answeredAt);
|
||||
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect } from 'vitest';
|
||||
import { cleanPrayer, cleanPrayers, mergePrayers, openPrayers } from './prayers.js';
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
const p = (id, extra = {}) => ({ id, text: `Pray ${id}`, forWhom: '', verses: [], prayedOn: [], createdAt: NOW - 5000, updatedAt: NOW - 1000, answeredAt: null, answerNote: '', ...extra });
|
||||
|
||||
test('only well-formed prayers are kept, rebuilt field by field', () => {
|
||||
const out = cleanPrayer({ id: 'a-1', text: ' For my family ', forWhom: 'Home', evil: '<script>', verses: [{ book: 'PHP', chapter: 4, verse: 6, label: 'Philippians 4:6' }, { book: 'xx', chapter: 1 }, { book: 'JHN', chapter: 99999 }], prayedOn: ['2026-10-01', 'nope', '2026-10-01'], updatedAt: NOW - 1 }, NOW);
|
||||
expect(out).toMatchObject({ id: 'a-1', text: 'For my family', forWhom: 'Home', prayedOn: ['2026-10-01'], answeredAt: null });
|
||||
expect(out.verses).toEqual([{ book: 'PHP', chapter: 4, verse: 6, label: 'Philippians 4:6' }]);
|
||||
expect('evil' in out).toBe(false);
|
||||
expect(cleanPrayer({ id: 'bad id!', text: 'x' }, NOW)).toBeNull();
|
||||
expect(cleanPrayer({ id: 'a', text: ' ' }, NOW)).toBeNull();
|
||||
expect(cleanPrayer(null)).toBeNull();
|
||||
expect(cleanPrayer({ id: 'g', deleted: true, updatedAt: NOW }, NOW)).toEqual({ id: 'g', deleted: true, updatedAt: NOW });
|
||||
expect(cleanPrayers([p('a'), p('a'), 5, p('b')], NOW).map((x) => x.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('the newer version of a prayer wins and the days prayed are combined', () => {
|
||||
const mine = [p('a', { text: 'old wording', prayedOn: ['2026-10-01'], updatedAt: NOW - 5000 })];
|
||||
const theirs = [p('a', { text: 'new wording', prayedOn: ['2026-10-02'], updatedAt: NOW - 1000 }), p('b')];
|
||||
const merged = mergePrayers(mine, theirs, NOW);
|
||||
expect(merged.find((x) => x.id === 'a')).toMatchObject({ text: 'new wording', prayedOn: ['2026-10-01', '2026-10-02'] });
|
||||
expect(merged.map((x) => x.id).sort()).toEqual(['a', 'b']);
|
||||
expect(mergePrayers(merged, merged, NOW)).toEqual(merged);
|
||||
});
|
||||
|
||||
test('a deleted prayer is not brought back by an older copy, and old markers are forgotten', () => {
|
||||
const gone = [{ id: 'a', deleted: true, updatedAt: NOW - 100 }];
|
||||
const stale = [p('a', { updatedAt: NOW - 9000 })];
|
||||
expect(mergePrayers(gone, stale, NOW)).toEqual([{ id: 'a', deleted: true, updatedAt: NOW - 100 }]);
|
||||
expect(mergePrayers([{ id: 'z', deleted: true, updatedAt: NOW - 100 * 86_400_000 }], [], NOW)).toEqual([]);
|
||||
expect(mergePrayers([p('a', { updatedAt: NOW })], [{ id: 'a', deleted: true, updatedAt: NOW - 5 }], NOW)[0].deleted).toBeUndefined(); // written again after the delete
|
||||
});
|
||||
|
||||
test('open prayers are the ones not answered or deleted', () => {
|
||||
expect(openPrayers([p('a'), p('b', { answeredAt: NOW }), { id: 'c', deleted: true, updatedAt: 1 }]).map((x) => x.id)).toEqual(['a']);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
// A user's synced display preferences: theme, default Reader translation, verse layout, the
|
||||
// commentary they last chose, and where they left off in the Reader. Each preference carries
|
||||
// its own timestamp ({ v, at }) so the most recent change to each one wins, device by device.
|
||||
const ENUMS = {
|
||||
theme: ['paper', 'sepia', 'dark'],
|
||||
translation: ['BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET'],
|
||||
layout: ['flow', 'verses'],
|
||||
startPage: ['studies', 'reader', 'plans'], // where the app opens and where Home buttons go
|
||||
planTranslation: ['same', 'BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET'], // which Bible new reading plans start in
|
||||
commentarySource: ['matthew-henry', 'john-gill', 'jamieson-fausset-brown', 'tyndale', 'adam-clarke', 'john-calvin', 'keil-delitzsch'],
|
||||
};
|
||||
const DAY_MS = 86_400_000;
|
||||
const COLOR = /^#[0-9a-fA-F]{3,8}$/;
|
||||
|
||||
// A cleaned value, or undefined when it isn't acceptable for that preference.
|
||||
export function cleanPrefValue(key, value) {
|
||||
if (ENUMS[key]) return ENUMS[key].includes(value) ? value : undefined;
|
||||
if (key === 'colorLabels') {
|
||||
// What each highlight colour means to the reader: { "#fef08a": "Promises", ... }
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const out = {};
|
||||
for (const [color, label] of Object.entries(value).slice(0, 12)) {
|
||||
if (COLOR.test(color) && typeof label === 'string' && label.trim()) out[color] = label.trim().slice(0, 30);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (key === 'studyTemplates') {
|
||||
// The person's own study templates: prompts for the note boxes and scaffold text to start each chunk with
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const text = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
|
||||
const fields = (o, names, max) => {
|
||||
const out = {};
|
||||
for (const n of names) { const t = text(o?.[n], max); if (t.trim()) out[n] = t; }
|
||||
return out;
|
||||
};
|
||||
return value.slice(0, 12).filter((t) => t && typeof t.id === 'string' && /^user-[\w-]{1,40}$/.test(t.id) && typeof t.name === 'string' && t.name.trim()).map((t) => {
|
||||
const prompts = fields(t.prompts, ['observation', 'interpretation', 'application'], 800);
|
||||
const starters = fields(t.starters, ['generalNotes', 'observation', 'interpretation', 'application'], 2000);
|
||||
return { id: t.id, name: text(t.name, 40).trim(), description: text(t.description, 120), ...(Object.keys(prompts).length ? { prompts } : {}), ...(Object.keys(starters).length ? { starters } : {}) };
|
||||
});
|
||||
}
|
||||
if (key === 'position') {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const book = String(value.book ?? '').toUpperCase();
|
||||
if (!/^[1-3A-Z]{3}$/.test(book) || !Number.isInteger(value.chapter) || value.chapter < 1 || value.chapter > 200) return undefined;
|
||||
return { book, chapter: value.chapter };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Body -> { key: { v, at } } with only known keys, valid values and sane timestamps.
|
||||
export function cleanPrefsPatch(body, now = Date.now()) {
|
||||
const out = {};
|
||||
const prefs = body?.prefs;
|
||||
if (!prefs || typeof prefs !== 'object' || Array.isArray(prefs)) return out;
|
||||
for (const [key, entry] of Object.entries(prefs)) {
|
||||
const v = cleanPrefValue(key, entry?.v);
|
||||
const at = entry?.at;
|
||||
if (v === undefined || !Number.isFinite(at) || at < 0 || at > now + DAY_MS) continue;
|
||||
out[key] = { v, at };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Per key, the entry with the newer timestamp wins (the stored one on a tie).
|
||||
export function mergePrefs(existing = {}, patch = {}) {
|
||||
const merged = { ...existing };
|
||||
for (const [key, entry] of Object.entries(patch)) {
|
||||
if (!merged[key] || entry.at > merged[key].at) merged[key] = entry;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { test, expect } from 'vitest';
|
||||
import { cleanPrefValue, cleanPrefsPatch, mergePrefs } from './prefs.js';
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
|
||||
test('only known preferences with valid values are accepted', () => {
|
||||
expect(cleanPrefValue('theme', 'dark')).toBe('dark');
|
||||
expect(cleanPrefValue('theme', 'neon')).toBeUndefined();
|
||||
expect(cleanPrefValue('translation', 'ESV')).toBe('ESV');
|
||||
expect(cleanPrefValue('translation', 'KJV')).toBeUndefined();
|
||||
expect(cleanPrefValue('layout', 'flow')).toBe('flow');
|
||||
expect(cleanPrefValue('commentarySource', 'tyndale')).toBe('tyndale');
|
||||
expect(cleanPrefValue('startPage', 'reader')).toBe('reader');
|
||||
expect(cleanPrefValue('startPage', 'admin')).toBeUndefined();
|
||||
expect(cleanPrefValue('planTranslation', 'same')).toBe('same');
|
||||
expect(cleanPrefValue('planTranslation', 'NASB')).toBe('NASB');
|
||||
expect(cleanPrefValue('planTranslation', 'KJV')).toBeUndefined();
|
||||
expect(cleanPrefValue('fontSize', 2)).toBeUndefined(); // not a synced preference
|
||||
});
|
||||
|
||||
test('position needs a real book and chapter, and the book is normalised', () => {
|
||||
expect(cleanPrefValue('position', { book: 'jhn', chapter: 3 })).toEqual({ book: 'JHN', chapter: 3 });
|
||||
expect(cleanPrefValue('position', { book: 'JOHN', chapter: 3 })).toBeUndefined();
|
||||
expect(cleanPrefValue('position', { book: 'JHN', chapter: 0 })).toBeUndefined();
|
||||
expect(cleanPrefValue('position', { book: 'JHN', chapter: '3' })).toBeUndefined();
|
||||
expect(cleanPrefValue('position', null)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('cleanPrefsPatch drops unknown keys, bad values and silly timestamps', () => {
|
||||
const patch = cleanPrefsPatch({ prefs: {
|
||||
theme: { v: 'sepia', at: NOW },
|
||||
layout: { v: 'nope', at: NOW },
|
||||
evil: { v: 'x', at: NOW },
|
||||
translation: { v: 'NLT', at: 'yesterday' },
|
||||
commentarySource: { v: 'tyndale', at: NOW + 5 * 86_400_000 }, // far in the future
|
||||
position: { v: { book: 'GEN', chapter: 2 }, at: NOW - 10 },
|
||||
} }, NOW);
|
||||
expect(patch).toEqual({ theme: { v: 'sepia', at: NOW }, position: { v: { book: 'GEN', chapter: 2 }, at: NOW - 10 } });
|
||||
expect(cleanPrefsPatch(null)).toEqual({});
|
||||
expect(cleanPrefsPatch({ prefs: [] })).toEqual({});
|
||||
});
|
||||
|
||||
test('the newest change to each preference wins, independently', () => {
|
||||
const existing = { theme: { v: 'dark', at: 100 }, layout: { v: 'flow', at: 500 } };
|
||||
const patch = { theme: { v: 'paper', at: 200 }, layout: { v: 'verses', at: 300 }, translation: { v: 'ESV', at: 50 } };
|
||||
expect(mergePrefs(existing, patch)).toEqual({
|
||||
theme: { v: 'paper', at: 200 }, // patch is newer
|
||||
layout: { v: 'flow', at: 500 }, // stored is newer
|
||||
translation: { v: 'ESV', at: 50 }, // new key
|
||||
});
|
||||
expect(mergePrefs({ theme: { v: 'dark', at: 100 } }, { theme: { v: 'paper', at: 100 } }).theme.v).toBe('dark'); // tie keeps stored
|
||||
});
|
||||
|
||||
test('the database keeps one row per user', async () => {
|
||||
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'prefs-'));
|
||||
const db = await import('./db.js');
|
||||
db.initDb();
|
||||
db.createUser({ id: 'u1', email: 'a@example.test', passwordHash: 'x' });
|
||||
db.createUser({ id: 'u2', email: 'b@example.test', passwordHash: 'x' });
|
||||
expect(db.getUserPrefs('u1')).toEqual({});
|
||||
db.setUserPrefs('u1', { theme: { v: 'dark', at: 1 } });
|
||||
db.setUserPrefs('u1', { theme: { v: 'sepia', at: 2 } });
|
||||
expect(db.getUserPrefs('u1')).toEqual({ theme: { v: 'sepia', at: 2 } });
|
||||
expect(db.getUserPrefs('u2')).toEqual({});
|
||||
});
|
||||
|
||||
test('study templates are kept only when well-formed, and trimmed to sane sizes', async () => {
|
||||
const { cleanPrefValue } = await import('./prefs.js');
|
||||
const good = { id: 'user-abc1', name: ' Lesson ', description: 'x', prompts: { observation: 'Look', junk: 'no' }, starters: { generalNotes: 'Context:', bogus: 'no' } };
|
||||
expect(cleanPrefValue('studyTemplates', [good, { id: 'other', name: 'bad id' }, { id: 'user-2' }, null])).toEqual([
|
||||
{ id: 'user-abc1', name: 'Lesson', description: 'x', prompts: { observation: 'Look' }, starters: { generalNotes: 'Context:' } },
|
||||
]);
|
||||
expect(cleanPrefValue('studyTemplates', 'nope')).toBeUndefined();
|
||||
expect(cleanPrefValue('studyTemplates', Array.from({ length: 30 }, (_, i) => ({ id: `user-${i}`, name: `T${i}` }))).length).toBe(12);
|
||||
expect(cleanPrefValue('studyTemplates', [{ id: 'user-a', name: 'x', starters: { observation: 'y'.repeat(5000) } }])[0].starters.observation.length).toBe(2000);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
// Stops one device from silently overwriting a study another device has saved since. The browser sends
|
||||
// the `lastEdited` of the server copy it last saw (X-Base-Edited); if the server's copy has moved past
|
||||
// that, the save is refused with 409 and the person chooses whose version to keep.
|
||||
|
||||
// stored: the server copy's lastEdited (null when there is none); base: what the client last saw
|
||||
// (null/undefined when it never synced this study); force: the person chose "keep mine".
|
||||
export function isSaveConflict({ stored, base, force = false }) {
|
||||
if (force || stored == null) return false;
|
||||
if (!Number.isFinite(base)) return false; // a device that never saw the server's copy has nothing to compare
|
||||
return stored > base;
|
||||
}
|
||||
|
||||
// Reads the base header; anything that isn't a plain number is treated as "unknown".
|
||||
export function parseBase(header) {
|
||||
if (header == null || header === '') return null;
|
||||
const n = Number(header);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { isSaveConflict, parseBase } from './projectSync.js';
|
||||
|
||||
test('a save is fine when the server copy is no newer than what the device last saw', () => {
|
||||
expect(isSaveConflict({ stored: 100, base: 100 })).toBe(false);
|
||||
expect(isSaveConflict({ stored: 90, base: 100 })).toBe(false);
|
||||
});
|
||||
|
||||
test('a save conflicts when another device saved after the one this device last saw', () => {
|
||||
expect(isSaveConflict({ stored: 200, base: 100 })).toBe(true);
|
||||
});
|
||||
|
||||
test('first saves, unknown bases and "keep mine" never conflict', () => {
|
||||
expect(isSaveConflict({ stored: null, base: 100 })).toBe(false);
|
||||
expect(isSaveConflict({ stored: 200, base: null })).toBe(false);
|
||||
expect(isSaveConflict({ stored: 200, base: 100, force: true })).toBe(false);
|
||||
});
|
||||
|
||||
test('reads the base header', () => {
|
||||
expect(parseBase('1700000000000')).toBe(1700000000000);
|
||||
expect(parseBase('')).toBeNull();
|
||||
expect(parseBase(undefined)).toBeNull();
|
||||
expect(parseBase('abc')).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// What a person has read in the Reader: which chapters (how many times, and when last) and on which days.
|
||||
// Merging two copies never loses anything, so two devices can both add to it: each chapter keeps the larger
|
||||
// count and the later date, and the days are combined. The data comes from the browser, so it is rebuilt field by field.
|
||||
const KEY = /^[1-3A-Z]{3}\.\d{1,3}$/;
|
||||
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const MAX_DAYS = 5000;
|
||||
|
||||
export const emptyHistory = () => ({ chapters: {}, days: [] });
|
||||
|
||||
export function cleanHistory(raw) {
|
||||
const chapters = {};
|
||||
if (raw?.chapters && typeof raw.chapters === 'object' && !Array.isArray(raw.chapters)) {
|
||||
for (const [key, v] of Object.entries(raw.chapters).slice(0, 1300)) {
|
||||
if (!KEY.test(key) || !v || typeof v !== 'object') continue;
|
||||
const n = Number.isInteger(v.n) ? Math.min(9999, Math.max(1, v.n)) : 1;
|
||||
chapters[key] = { n, last: DATE.test(v.last ?? '') ? v.last : '' };
|
||||
}
|
||||
}
|
||||
const days = Array.isArray(raw?.days) ? [...new Set(raw.days.filter((d) => typeof d === 'string' && DATE.test(d)))].sort().slice(-MAX_DAYS) : [];
|
||||
return { chapters, days };
|
||||
}
|
||||
|
||||
export function mergeHistory(a, b) {
|
||||
const x = cleanHistory(a);
|
||||
const y = cleanHistory(b);
|
||||
const chapters = { ...x.chapters };
|
||||
for (const [key, v] of Object.entries(y.chapters)) {
|
||||
const mine = chapters[key];
|
||||
chapters[key] = mine ? { n: Math.max(mine.n, v.n), last: mine.last > v.last ? mine.last : v.last } : v;
|
||||
}
|
||||
return { chapters, days: [...new Set([...x.days, ...y.days])].sort().slice(-MAX_DAYS) };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect } from 'vitest';
|
||||
import { cleanHistory, mergeHistory } from './readerHistory.js';
|
||||
|
||||
test('only well-formed chapters and days are kept', () => {
|
||||
const out = cleanHistory({
|
||||
chapters: { 'JHN.3': { n: 2, last: '2026-10-01' }, 'bad': { n: 1 }, 'GEN.1': { n: -5, last: 'yesterday' }, '1JN.2': null },
|
||||
days: ['2026-10-02', 'nope', '2026-10-01', '2026-10-01'],
|
||||
});
|
||||
expect(out.chapters).toEqual({ 'JHN.3': { n: 2, last: '2026-10-01' }, 'GEN.1': { n: 1, last: '' } });
|
||||
expect(out.days).toEqual(['2026-10-01', '2026-10-02']);
|
||||
expect(cleanHistory(null)).toEqual({ chapters: {}, days: [] });
|
||||
});
|
||||
|
||||
test('merging keeps the larger count, the later date and every day', () => {
|
||||
const a = { chapters: { 'JHN.3': { n: 2, last: '2026-10-01' }, 'GEN.1': { n: 1, last: '2026-09-01' } }, days: ['2026-10-01'] };
|
||||
const b = { chapters: { 'JHN.3': { n: 1, last: '2026-10-05' }, 'ROM.8': { n: 1, last: '2026-10-04' } }, days: ['2026-10-04', '2026-10-05'] };
|
||||
const m = mergeHistory(a, b);
|
||||
expect(m.chapters['JHN.3']).toEqual({ n: 2, last: '2026-10-05' });
|
||||
expect(Object.keys(m.chapters).sort()).toEqual(['GEN.1', 'JHN.3', 'ROM.8']);
|
||||
expect(m.days).toEqual(['2026-10-01', '2026-10-04', '2026-10-05']);
|
||||
expect(mergeHistory(m, m)).toEqual(m); // merging again changes nothing
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// Validation for a user's synced Reader bookmarks and text highlights. The data comes
|
||||
// from the browser, so everything is checked and malformed entries are dropped.
|
||||
const COLOR = /^#[0-9a-fA-F]{3,8}$/;
|
||||
const BOOKMARK_KEY = /^[1-3]?[A-Za-z]{2,3}-\d{1,3}-\d{1,3}$/; // e.g. "JHN-3-16", "1SA-3-5"
|
||||
const BOOK = /^[1-3A-Za-z]{3}$/;
|
||||
const MAX_BOOKMARKS = 5000;
|
||||
const MAX_HIGHLIGHTS = 20000;
|
||||
const MAX_NOTE = 500;
|
||||
|
||||
const int = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
|
||||
|
||||
// Returns { bookmarks, highlights } with only well-formed entries, or null when the body is
|
||||
// not an object at all.
|
||||
export function cleanMarks(body) {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return null;
|
||||
|
||||
const bookmarks = {};
|
||||
if (body.bookmarks && typeof body.bookmarks === 'object' && !Array.isArray(body.bookmarks)) {
|
||||
for (const [key, color] of Object.entries(body.bookmarks).slice(0, MAX_BOOKMARKS)) {
|
||||
if (BOOKMARK_KEY.test(key) && typeof color === 'string' && COLOR.test(color)) bookmarks[key] = color;
|
||||
}
|
||||
}
|
||||
|
||||
// A short note on a bookmark: { "JHN-3-16": "my note" }
|
||||
const bookmarkNotes = {};
|
||||
if (body.bookmarkNotes && typeof body.bookmarkNotes === 'object' && !Array.isArray(body.bookmarkNotes)) {
|
||||
for (const [key, note] of Object.entries(body.bookmarkNotes).slice(0, MAX_BOOKMARKS)) {
|
||||
if (BOOKMARK_KEY.test(key) && typeof note === 'string' && note.trim()) bookmarkNotes[key] = note.slice(0, MAX_NOTE);
|
||||
}
|
||||
}
|
||||
|
||||
const highlights = [];
|
||||
if (Array.isArray(body.highlights)) {
|
||||
const seen = new Set();
|
||||
for (const h of body.highlights.slice(0, MAX_HIGHLIGHTS)) {
|
||||
if (!h || typeof h !== 'object') continue;
|
||||
if (typeof h.id !== 'string' || h.id.length === 0 || h.id.length > 64 || seen.has(h.id)) continue;
|
||||
if (!BOOK.test(String(h.book)) || !int(h.chapter, 1, 200) || !int(h.verse, 1, 200)) continue;
|
||||
if (!int(h.startOffset, 0, 5000) || !int(h.endOffset, 1, 5000) || h.endOffset <= h.startOffset) continue;
|
||||
if (typeof h.color !== 'string' || !COLOR.test(h.color)) continue;
|
||||
seen.add(h.id);
|
||||
const entry = {
|
||||
id: h.id, book: String(h.book).toUpperCase(), chapter: h.chapter, verse: h.verse,
|
||||
startOffset: h.startOffset, endOffset: h.endOffset, color: h.color,
|
||||
};
|
||||
// The highlighted words, kept so lists of highlights can show them without loading the chapter
|
||||
if (typeof h.text === 'string' && h.text.length > 0) entry.text = h.text.slice(0, 500);
|
||||
// Which translation the words were selected in, and whether it is a whole-verse highlight
|
||||
if (typeof h.translation === 'string' && /^[A-Za-z0-9]{2,8}$/.test(h.translation)) entry.translation = h.translation;
|
||||
if (h.whole === true) entry.whole = true;
|
||||
if (typeof h.note === 'string' && h.note.trim()) entry.note = h.note.slice(0, MAX_NOTE);
|
||||
highlights.push(entry);
|
||||
}
|
||||
}
|
||||
return { bookmarks, highlights, bookmarkNotes };
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { test, expect } from 'vitest';
|
||||
import { cleanMarks } from './readerMarks.js';
|
||||
|
||||
const hl = (over = {}) => ({ id: 'h1', book: 'JHN', chapter: 3, verse: 16, startOffset: 4, endOffset: 20, color: '#fef08a', ...over });
|
||||
|
||||
test('a non-object body is rejected', () => {
|
||||
expect(cleanMarks(null)).toBeNull();
|
||||
expect(cleanMarks([])).toBeNull();
|
||||
expect(cleanMarks('x')).toBeNull();
|
||||
});
|
||||
|
||||
test('good bookmarks and highlights pass through unchanged', () => {
|
||||
const body = { bookmarks: { 'JHN-3-16': '#fef08a', '1SA-3-5': '#bbf7d0' }, highlights: [hl()] };
|
||||
expect(cleanMarks(body)).toEqual({ ...body, bookmarkNotes: {} });
|
||||
});
|
||||
|
||||
test('malformed bookmarks are dropped, good ones kept', () => {
|
||||
const out = cleanMarks({ bookmarks: { 'JHN-3-16': '#fef08a', 'bad key': '#fef08a', 'GEN-1-1': 'red', 'GEN-1-2': 5, '../x-1-1': '#fff' } });
|
||||
expect(out.bookmarks).toEqual({ 'JHN-3-16': '#fef08a' });
|
||||
});
|
||||
|
||||
test('highlights are validated field by field and de-duplicated by id', () => {
|
||||
const out = cleanMarks({ highlights: [
|
||||
hl(),
|
||||
hl({ id: 'h1', verse: 17 }), // duplicate id
|
||||
hl({ id: '' }), hl({ id: 'x'.repeat(65) }),
|
||||
hl({ id: 'a', book: 'JOHN' }), hl({ id: 'b', chapter: 0 }), hl({ id: 'c', verse: 201 }),
|
||||
hl({ id: 'd', startOffset: 10, endOffset: 10 }), hl({ id: 'e', startOffset: -1 }), hl({ id: 'f', color: 'yellow' }),
|
||||
'nope', null,
|
||||
hl({ id: 'ok', book: 'gen' }),
|
||||
] });
|
||||
expect(out.highlights.map((h) => h.id)).toEqual(['h1', 'ok']);
|
||||
expect(out.highlights[1].book).toBe('GEN'); // normalised
|
||||
});
|
||||
|
||||
test('missing sections become empty ones, and unknown fields are not stored', () => {
|
||||
expect(cleanMarks({})).toEqual({ bookmarks: {}, highlights: [], bookmarkNotes: {} });
|
||||
expect(cleanMarks({ highlights: [{ ...hl(), evil: '<script>' }] }).highlights[0]).not.toHaveProperty('evil');
|
||||
});
|
||||
|
||||
test('the database keeps one row per user and updatedAt only moves forward', async () => {
|
||||
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'marks-'));
|
||||
const db = await import('./db.js');
|
||||
db.initDb();
|
||||
db.createUser({ id: 'u1', email: 'a@example.test', passwordHash: 'x' });
|
||||
db.createUser({ id: 'u2', email: 'b@example.test', passwordHash: 'x' });
|
||||
expect(db.getReaderMarks('u1')).toEqual({ bookmarks: {}, highlights: [], bookmarkNotes: {}, updatedAt: 0 });
|
||||
const t1 = db.setReaderMarks('u1', { bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [] });
|
||||
const t2 = db.setReaderMarks('u1', { bookmarks: {}, highlights: [hl()] });
|
||||
expect(t2).toBeGreaterThan(t1);
|
||||
const saved = db.getReaderMarks('u1');
|
||||
expect(saved.updatedAt).toBe(t2);
|
||||
expect(saved.bookmarks).toEqual({});
|
||||
expect(saved.highlights).toHaveLength(1);
|
||||
expect(db.getReaderMarks('u2').updatedAt).toBe(0); // other users are untouched
|
||||
});
|
||||
|
||||
test('a highlight keeps the translation it was made in and whether it covers the whole verse', async () => {
|
||||
const { cleanMarks } = await import('./readerMarks.js');
|
||||
const base = { id: 'a', book: 'JHN', chapter: 3, verse: 16, startOffset: 0, endOffset: 40, color: '#fef08a', text: 'For God so loved' };
|
||||
const out = cleanMarks({ highlights: [
|
||||
{ ...base, translation: 'BSB', whole: true },
|
||||
{ ...base, id: 'b', translation: 'not a code!!', whole: 'yes' },
|
||||
] }).highlights;
|
||||
expect(out[0]).toMatchObject({ translation: 'BSB', whole: true });
|
||||
expect(out[1].translation).toBeUndefined();
|
||||
expect(out[1].whole).toBeUndefined();
|
||||
});
|
||||
|
||||
test('notes on highlights and bookmarks are kept, trimmed, and dropped when empty or malformed', async () => {
|
||||
const { cleanMarks } = await import('./readerMarks.js');
|
||||
const base = { book: 'JHN', chapter: 3, verse: 16, startOffset: 0, endOffset: 40, color: '#fef08a' };
|
||||
const out = cleanMarks({
|
||||
bookmarks: { 'JHN-3-16': '#fef08a' },
|
||||
bookmarkNotes: { 'JHN-3-16': 'Memorise this', 'JHN-3-17': ' ', 'bad key': 'x', 'ROM-8-1': 5 },
|
||||
highlights: [{ ...base, id: 'a', note: 'The heart of the gospel' }, { ...base, id: 'b', note: ' ' }, { ...base, id: 'c', note: 'x'.repeat(900) }],
|
||||
});
|
||||
expect(out.bookmarkNotes).toEqual({ 'JHN-3-16': 'Memorise this' });
|
||||
expect(out.highlights[0].note).toBe('The heart of the gospel');
|
||||
expect(out.highlights[1].note).toBeUndefined();
|
||||
expect(out.highlights[2].note).toHaveLength(500);
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
// Daily "don't forget to read" reminders, sent via ntfy.sh (https://ntfy.sh) —
|
||||
// a free pub/sub push service: POST a message to a topic URL, anyone
|
||||
// subscribed to that topic (via the ntfy app or a browser) gets notified.
|
||||
// No API keys or push-subscription plumbing needed.
|
||||
import { lookup } from 'dns/promises';
|
||||
import { isIP } from 'net';
|
||||
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress, getUsersWithVotdEnabled, setLastVotdDate, getUsersWithPrayerReminder, setLastPrayerDate, getPrayers } from './db.js';
|
||||
import { openPrayers } from './prayers.js';
|
||||
import { logEvent } from './health.js';
|
||||
import { fetchVerseOfDay, votdMessage } from './votd.js';
|
||||
|
||||
// A reminder still goes out if the server was briefly down (or restarting) at the
|
||||
// exact minute — as long as it's within this window after the chosen time.
|
||||
const GRACE_MINUTES = 180;
|
||||
const SEND_TIMEOUT_MS = 10_000;
|
||||
// A failed send is retried at most this often, so a dead ntfy server isn't hit every minute for hours.
|
||||
const RETRY_AFTER_MS = 15 * 60 * 1000;
|
||||
const _lastAttempt = new Map(); // userId -> timestamp of the last failed send
|
||||
|
||||
// True for loopback, private, link-local and other non-public addresses (IPv4 and IPv6).
|
||||
export function isPrivateAddress(addr) {
|
||||
const v = isIP(addr);
|
||||
if (v === 4) {
|
||||
const [a, b] = addr.split('.').map(Number);
|
||||
return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
|
||||
|| (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224;
|
||||
}
|
||||
if (v === 6) {
|
||||
const l = addr.toLowerCase();
|
||||
if (l === '::' || l === '::1') return true;
|
||||
if (l.startsWith('::ffff:')) return isPrivateAddress(l.slice(7));
|
||||
return /^f[cd]/.test(l) || /^fe[89ab]/.test(l);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Rejects server URLs that would make this server call itself or its private network.
|
||||
// A self-hosted ntfy on the LAN can be allowed by the server's admin with ALLOW_PRIVATE_NTFY=1.
|
||||
export async function assertPublicUrl(raw) {
|
||||
if (process.env.ALLOW_PRIVATE_NTFY === '1') return;
|
||||
let u;
|
||||
try { u = new URL(raw); } catch { throw new Error('Server URL is not valid.'); }
|
||||
if (!/^https?:$/.test(u.protocol)) throw new Error('Server URL must start with http:// or https://.');
|
||||
const host = u.hostname.replace(/^\[|\]$/g, '');
|
||||
if (/^localhost$|\.localhost$|\.local$|\.internal$/i.test(host)) throw new Error('Server URL must be a public address.');
|
||||
const addrs = isIP(host) ? [host] : (await lookup(host, { all: true }).catch(() => [])).map((a) => a.address);
|
||||
if (addrs.length === 0) throw new Error(`Could not find ${host}.`);
|
||||
if (addrs.some(isPrivateAddress)) throw new Error('Server URL must be a public address.');
|
||||
}
|
||||
|
||||
export function isValidTimeZone(tz) {
|
||||
if (!tz) return false;
|
||||
try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
// The calendar date ("YYYY-MM-DD") and minutes-since-midnight in a timezone; the
|
||||
// server's own timezone when none (or an invalid one) is saved.
|
||||
export function zonedNow(date = new Date(), tz) {
|
||||
const timeZone = isValidTimeZone(tz) ? tz : undefined;
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||
}).formatToParts(date).map((p) => [p.type, p.value]),
|
||||
);
|
||||
return { date: `${parts.year}-${parts.month}-${parts.day}`, minutes: Number(parts.hour) * 60 + Number(parts.minute) };
|
||||
}
|
||||
|
||||
// Is this user's reminder due right now (and not already sent today)?
|
||||
export function isDue(user, now = new Date()) {
|
||||
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(user.reminderTime ?? '');
|
||||
if (!match) return false;
|
||||
const target = Number(match[1]) * 60 + Number(match[2]);
|
||||
const local = zonedNow(now, user.reminderTz);
|
||||
if (user.lastReminderDate === local.date) return false;
|
||||
return local.minutes >= target && local.minutes < target + GRACE_MINUTES;
|
||||
}
|
||||
|
||||
// POSTs one notification to the user's ntfy topic. Throws with a readable message on failure.
|
||||
export async function sendNtfy(user, { title, body, tags = 'open_book' }) {
|
||||
const base = (user.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '');
|
||||
const url = `${base}/${encodeURIComponent(user.ntfyTopic)}`;
|
||||
await assertPublicUrl(url);
|
||||
const headers = { Title: title, Tags: tags };
|
||||
if (process.env.APP_URL) headers.Click = process.env.APP_URL; // tapping the notification opens the app
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, { method: 'POST', headers, body, redirect: 'error', signal: AbortSignal.timeout(SEND_TIMEOUT_MS) });
|
||||
} catch (err) {
|
||||
throw new Error(err.name === 'TimeoutError' ? `Timed out reaching ${base}` : `Could not reach ${base} (${err.cause?.code ?? err.message})`, { cause: err });
|
||||
}
|
||||
if (!res.ok) throw new Error(`ntfy responded ${res.status}`);
|
||||
}
|
||||
|
||||
// Has the user already finished a reading day on `date` ("YYYY-MM-DD", in their timezone)?
|
||||
export function readToday(progress, date) {
|
||||
return Array.isArray(progress?.activity) && progress.activity.includes(date);
|
||||
}
|
||||
|
||||
export function reminderBody(progress) {
|
||||
const active = (Array.isArray(progress?.progress) ? progress.progress : []).filter((p) => !p.completedAt).length;
|
||||
return active > 1 ? `Don't forget today's reading! You have ${active} plans in progress.` : "Don't forget today's reading!";
|
||||
}
|
||||
|
||||
/** Sends a one-off notification so the user can confirm their topic/server work. */
|
||||
export function sendTestReminder(user) {
|
||||
return sendNtfy(user, { title: 'Reading reminders are working', body: 'This is a test from Bible Study Project. Your daily reminder will arrive like this.', tags: 'white_check_mark' });
|
||||
}
|
||||
|
||||
export async function checkReminders(now = new Date()) {
|
||||
for (const user of getUsersWithRemindersEnabled().filter((u) => isDue(u, now))) {
|
||||
try {
|
||||
const progress = getReadingProgress(user.id);
|
||||
const hasActivePlan = Array.isArray(progress?.progress) && progress.progress.some((p) => !p.completedAt);
|
||||
if (!hasActivePlan) continue; // nothing to nudge them about
|
||||
if (readToday(progress, zonedNow(now, user.reminderTz).date)) continue; // already read today: no nudge
|
||||
if (now.getTime() - (_lastAttempt.get(user.id) ?? 0) < RETRY_AFTER_MS) continue;
|
||||
_lastAttempt.set(user.id, now.getTime());
|
||||
await sendNtfy(user, { title: 'Reading Plan Reminder', body: reminderBody(progress) });
|
||||
_lastAttempt.delete(user.id);
|
||||
setLastReminderDate(user.id, zonedNow(now, user.reminderTz).date);
|
||||
} catch (err) {
|
||||
console.error(`Reading reminder failed for user ${user.id}:`, err.message);
|
||||
logEvent('notify', `Reading reminder: ${err.message}`, { userId: user.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sends the verse of the day to everyone who asked for it, at their own time. One verse a day, the same for all.
|
||||
export async function checkVotd(now = new Date()) {
|
||||
const due = getUsersWithVotdEnabled().filter((u) => isDue({ reminderTime: u.votdTime, reminderTz: u.reminderTz, lastReminderDate: u.lastVotdDate }, now));
|
||||
if (due.length === 0) return;
|
||||
const verse = await fetchVerseOfDay(now);
|
||||
if (!verse) return; // no key, or IQ Bible didn't answer: try again next minute (within the grace window)
|
||||
for (const user of due) {
|
||||
const key = `votd:${user.id}`;
|
||||
if (now.getTime() - (_lastAttempt.get(key) ?? 0) < RETRY_AFTER_MS) continue;
|
||||
_lastAttempt.set(key, now.getTime());
|
||||
try {
|
||||
await sendNtfy(user, votdMessage(verse));
|
||||
_lastAttempt.delete(key);
|
||||
setLastVotdDate(user.id, zonedNow(now, user.reminderTz).date);
|
||||
} catch (err) {
|
||||
console.error(`Verse of the day failed for user ${user.id}:`, err.message);
|
||||
logEvent('notify', `Verse of the day: ${err.message}`, { userId: user.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The message for the daily prayer reminder: how many requests are open, and the first few.
|
||||
export function prayerMessage(open) {
|
||||
const lines = open.slice(0, 3).map((p) => `• ${p.forWhom ? `${p.forWhom}: ` : ''}${p.text.replace(/\s+/g, ' ').slice(0, 80)}${p.text.length > 80 ? '…' : ''}`);
|
||||
const more = open.length - lines.length;
|
||||
return {
|
||||
title: open.length === 1 ? 'Time to pray: 1 request' : `Time to pray: ${open.length} requests`,
|
||||
body: `${lines.join('\n')}${more > 0 ? `\n…and ${more} more` : ''}`,
|
||||
tags: 'pray',
|
||||
};
|
||||
}
|
||||
|
||||
// Sends each person who asked for it a nudge to pray, with what they are praying about. Nothing is sent when
|
||||
// the journal has no open requests.
|
||||
export async function checkPrayerReminders(now = new Date()) {
|
||||
for (const user of getUsersWithPrayerReminder().filter((u) => isDue({ reminderTime: u.prayerTime, reminderTz: u.reminderTz, lastReminderDate: u.lastPrayerDate }, now))) {
|
||||
const open = openPrayers(getPrayers(user.id));
|
||||
if (open.length === 0) continue;
|
||||
const key = `prayer:${user.id}`;
|
||||
if (now.getTime() - (_lastAttempt.get(key) ?? 0) < RETRY_AFTER_MS) continue;
|
||||
_lastAttempt.set(key, now.getTime());
|
||||
try {
|
||||
await sendNtfy(user, prayerMessage(open));
|
||||
_lastAttempt.delete(key);
|
||||
setLastPrayerDate(user.id, zonedNow(now, user.reminderTz).date);
|
||||
} catch (err) {
|
||||
console.error(`Prayer reminder failed for user ${user.id}:`, err.message);
|
||||
logEvent('notify', `Prayer reminder: ${err.message}`, { userId: user.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks once a minute whether any user's reminder time has arrived. */
|
||||
export function startReminderScheduler() {
|
||||
setInterval(() => {
|
||||
checkReminders().catch((err) => console.error('Reminder scheduler error:', err));
|
||||
checkVotd().catch((err) => console.error('Verse of the day scheduler error:', err));
|
||||
checkPrayerReminders().catch((err) => console.error('Prayer reminder scheduler error:', err));
|
||||
}, 60 * 1000);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// @vitest-environment node
|
||||
import http from 'node:http';
|
||||
import { test, expect, afterEach } from 'vitest';
|
||||
process.env.ALLOW_PRIVATE_NTFY = '1'; // the tests talk to a local server
|
||||
import { isDue, isValidTimeZone, reminderBody, sendNtfy, sendTestReminder, zonedNow } from './reminders.js';
|
||||
|
||||
const at = (iso) => new Date(iso);
|
||||
|
||||
test('zonedNow reads the date and time in the user\'s timezone, not the server\'s', () => {
|
||||
// 2026-09-30T03:30Z is 23:30 the day before in New York, and 12:30 the same day in Tokyo
|
||||
const d = at('2026-09-30T03:30:00Z');
|
||||
expect(zonedNow(d, 'America/New_York')).toEqual({ date: '2026-09-29', minutes: 23 * 60 + 30 });
|
||||
expect(zonedNow(d, 'Asia/Tokyo')).toEqual({ date: '2026-09-30', minutes: 12 * 60 + 30 });
|
||||
expect(zonedNow(d, 'Not/AZone')).toEqual(zonedNow(d)); // bad timezone falls back to the server's
|
||||
});
|
||||
|
||||
test('timezone validation', () => {
|
||||
expect(isValidTimeZone('America/Chicago')).toBe(true);
|
||||
expect(isValidTimeZone('Mars/Base')).toBe(false);
|
||||
expect(isValidTimeZone('')).toBe(false);
|
||||
});
|
||||
|
||||
test('a reminder is due at its time in the user\'s timezone', () => {
|
||||
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: null };
|
||||
expect(isDue(user, at('2026-09-29T11:59:00Z'))).toBe(false); // 06:59 in Chicago (CDT)
|
||||
expect(isDue(user, at('2026-09-29T12:00:00Z'))).toBe(true); // 07:00
|
||||
});
|
||||
|
||||
test('a late server still sends today\'s reminder, but only within the grace window', () => {
|
||||
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: null };
|
||||
expect(isDue(user, at('2026-09-29T12:40:00Z'))).toBe(true); // 07:40 — server was down at 07:00
|
||||
expect(isDue(user, at('2026-09-29T15:30:00Z'))).toBe(false); // 10:30 — too late to be useful
|
||||
});
|
||||
|
||||
test('it is not sent twice on the same local day', () => {
|
||||
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: '2026-09-29' };
|
||||
expect(isDue(user, at('2026-09-29T12:05:00Z'))).toBe(false);
|
||||
expect(isDue({ ...user, lastReminderDate: '2026-09-28' }, at('2026-09-29T12:05:00Z'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a missing or malformed time is never due', () => {
|
||||
expect(isDue({ reminderTime: null }, new Date())).toBe(false);
|
||||
expect(isDue({ reminderTime: '7am' }, new Date())).toBe(false);
|
||||
});
|
||||
|
||||
test('the message mentions how many plans are in progress', () => {
|
||||
expect(reminderBody({ progress: [{ completedAt: null }] })).toBe("Don't forget today's reading!");
|
||||
expect(reminderBody({ progress: [{ completedAt: null }, { completedAt: null }, { completedAt: 1 }] })).toContain('2 plans');
|
||||
});
|
||||
|
||||
// --- delivery, against a local stand-in for an ntfy server ------------------------
|
||||
let server;
|
||||
afterEach(() => new Promise((resolve) => (server ? server.close(resolve) : resolve())));
|
||||
|
||||
const startNtfy = (handler) => new Promise((resolve) => {
|
||||
const seen = [];
|
||||
server = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; });
|
||||
req.on('end', () => { seen.push({ method: req.method, url: req.url, headers: req.headers, body }); handler(res); });
|
||||
});
|
||||
server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, seen }));
|
||||
});
|
||||
|
||||
test('sendNtfy POSTs the message to <server>/<topic> with a title', async () => {
|
||||
const ntfy = await startNtfy((res) => res.end('{}'));
|
||||
await sendNtfy({ ntfyUrl: `${ntfy.url}/`, ntfyTopic: 'my-topic_1' }, { title: 'Hello', body: 'Body text' });
|
||||
expect(ntfy.seen).toHaveLength(1);
|
||||
expect(ntfy.seen[0]).toMatchObject({ method: 'POST', url: '/my-topic_1', body: 'Body text' });
|
||||
expect(ntfy.seen[0].headers.title).toBe('Hello');
|
||||
});
|
||||
|
||||
test('the test notification reaches the topic', async () => {
|
||||
const ntfy = await startNtfy((res) => res.end('{}'));
|
||||
await sendTestReminder({ ntfyUrl: ntfy.url, ntfyTopic: 'abc' });
|
||||
expect(ntfy.seen[0].url).toBe('/abc');
|
||||
expect(ntfy.seen[0].headers.title).toMatch(/working/i);
|
||||
});
|
||||
|
||||
test('an ntfy error response becomes a readable failure', async () => {
|
||||
const ntfy = await startNtfy((res) => { res.statusCode = 403; res.end('nope'); });
|
||||
await expect(sendNtfy({ ntfyUrl: ntfy.url, ntfyTopic: 'abc' }, { title: 't', body: 'b' })).rejects.toThrow('ntfy responded 403');
|
||||
});
|
||||
|
||||
test('an unreachable server says so instead of hanging', async () => {
|
||||
await expect(sendNtfy({ ntfyUrl: 'http://127.0.0.1:9', ntfyTopic: 'abc' }, { title: 't', body: 'b' })).rejects.toThrow(/Could not reach/);
|
||||
});
|
||||
|
||||
test('private and loopback addresses are rejected as reminder servers', async () => {
|
||||
const { isPrivateAddress, assertPublicUrl, readToday } = await import('./reminders.js');
|
||||
for (const a of ['127.0.0.1', '10.1.2.3', '192.168.0.5', '172.16.0.1', '169.254.169.254', '::1', 'fd00::1', '::ffff:10.0.0.1']) expect(isPrivateAddress(a)).toBe(true);
|
||||
expect(isPrivateAddress('8.8.8.8')).toBe(false);
|
||||
const prev = process.env.ALLOW_PRIVATE_NTFY;
|
||||
delete process.env.ALLOW_PRIVATE_NTFY;
|
||||
await expect(assertPublicUrl('http://localhost:3001')).rejects.toThrow();
|
||||
await expect(assertPublicUrl('http://169.254.169.254/latest')).rejects.toThrow();
|
||||
await expect(assertPublicUrl('ftp://example.com')).rejects.toThrow();
|
||||
process.env.ALLOW_PRIVATE_NTFY = prev;
|
||||
expect(readToday({ activity: ['2026-09-29'] }, '2026-09-29')).toBe(true);
|
||||
expect(readToday({ activity: [] }, '2026-09-29')).toBe(false);
|
||||
});
|
||||
|
||||
test('the prayer reminder lists the first few open requests and counts the rest', async () => {
|
||||
const { prayerMessage } = await import('./reminders.js');
|
||||
const open = [
|
||||
{ text: 'Healing for the whole family and a peaceful recovery after the surgery next week, with no complications at all', forWhom: 'Mum' },
|
||||
{ text: 'Wisdom at work', forWhom: '' }, { text: 'Rain', forWhom: 'Farm' }, { text: 'Peace', forWhom: '' }, { text: 'Joy', forWhom: '' },
|
||||
];
|
||||
const m = prayerMessage(open);
|
||||
expect(m.title).toBe('Time to pray: 5 requests');
|
||||
expect(m.body.split('\n')).toEqual([`• Mum: ${open[0].text.slice(0, 80)}…`, '• Wisdom at work', '• Farm: Rain', '…and 2 more']);
|
||||
expect(prayerMessage([{ text: 'One', forWhom: '' }]).title).toBe('Time to pray: 1 request');
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
anon GET /api/health 200
|
||||
anon GET /api/auth/signup-mode 200
|
||||
anon GET /api/auth/me 401
|
||||
anon GET /api/projects 401
|
||||
anon GET /api/admin/users 401
|
||||
admin POST /api/auth/register 200
|
||||
sam POST /api/auth/register 200
|
||||
anon POST /api/auth/register 400
|
||||
anon POST /api/auth/login 401
|
||||
admin POST /api/auth/login 200
|
||||
admin GET /api/auth/me 200
|
||||
sam GET /api/auth/me 200
|
||||
anon POST /api/auth/forgot 200
|
||||
anon POST /api/auth/reset 400
|
||||
admin PATCH /api/auth/profile 200
|
||||
admin PATCH /api/auth/reminder-settings 400
|
||||
admin POST /api/auth/reminder-test 400
|
||||
admin POST /api/auth/change-password 401
|
||||
admin POST /api/auth/mfa/setup 200
|
||||
admin POST /api/auth/mfa/enable 401
|
||||
anon POST /api/auth/mfa/verify 400
|
||||
admin GET /api/account/export 200
|
||||
admin PUT /api/projects/s1 200
|
||||
admin PUT /api/projects/s1 400
|
||||
admin PUT /api/projects/s1 400
|
||||
admin GET /api/projects 200
|
||||
admin GET /api/projects/s1 200
|
||||
sam GET /api/projects/s1 404
|
||||
admin PUT /api/projects/s1 409
|
||||
admin GET /api/projects/s1/versions 200
|
||||
admin GET /api/projects/s1/versions/999 404
|
||||
admin POST /api/projects/s1/versions/999/restore 404
|
||||
sam GET /api/projects/s1/versions 404
|
||||
admin PUT /api/reader/ink/ROM/8 200
|
||||
admin PUT /api/reader/ink/ROM/8 400
|
||||
admin PUT /api/reader/ink/ROMANS/8 400
|
||||
admin GET /api/reader/ink 200
|
||||
admin PUT /api/prefs 200
|
||||
admin GET /api/prefs 200
|
||||
admin PUT /api/reader/marks 200
|
||||
admin GET /api/reader/marks 200
|
||||
admin PUT /api/reader/marks 400
|
||||
admin PUT /api/prayers 200
|
||||
admin GET /api/prayers 200
|
||||
admin PUT /api/reader/history 200
|
||||
admin GET /api/reader/history 200
|
||||
admin PUT /api/reading-plans 200
|
||||
admin PUT /api/reading-plans 400
|
||||
admin GET /api/reading-plans 200
|
||||
admin GET /api/iq/status 200
|
||||
admin GET /api/iq/nope 404
|
||||
admin GET /api/passkeys 200
|
||||
admin GET /api/sessions 200
|
||||
admin POST /api/auth/passkey/options 200
|
||||
anon GET /api/bible/nope 404
|
||||
admin POST /api/projects/s1/share 200
|
||||
admin GET /api/projects/s1/share 200
|
||||
anon GET /api/share/:token 200
|
||||
anon GET /api/share/doesnotexist 404
|
||||
admin POST /api/projects/s1/outline-share 200
|
||||
anon GET /api/share-outline/:token 200
|
||||
admin GET /api/projects/s1/outline-share 200
|
||||
admin DELETE /api/projects/s1/outline-share 200
|
||||
anon GET /api/share-outline/:token 404
|
||||
admin DELETE /api/projects/s1/share 200
|
||||
admin POST /api/projects/s1/collaborators/invite 200
|
||||
sam POST /api/join/:token 200
|
||||
admin GET /api/projects/s1/collaborators 200
|
||||
sam GET /api/projects/s1/versions 200
|
||||
admin DELETE /api/projects/s1/collaborators/:id 200
|
||||
sam GET /api/admin/users 403
|
||||
admin GET /api/admin/users 200
|
||||
admin GET /api/admin/projects 200
|
||||
admin GET /api/admin/projects/s1 200
|
||||
admin GET /api/admin/signup 200
|
||||
admin PUT /api/admin/signup 400
|
||||
admin PUT /api/admin/signup 200
|
||||
admin POST /api/admin/invites 200
|
||||
admin DELETE /api/admin/invites/:code 200
|
||||
admin GET /api/admin/health 200
|
||||
admin POST /api/admin/backups 200
|
||||
admin GET /api/admin/backups/nope.db 404
|
||||
admin POST /api/admin/backups/projects-2000-01-01-00-00.db/restore 404
|
||||
sam GET /api/auth/me 200
|
||||
admin POST /api/admin/users/:id/reset-password 200
|
||||
sam GET /api/auth/me 401
|
||||
admin DELETE /api/admin/users/:id 200
|
||||
admin DELETE /api/projects/s1 200
|
||||
admin POST /api/auth/logout 200
|
||||
admin GET /api/auth/me 401
|
||||
@@ -0,0 +1,167 @@
|
||||
// @vitest-environment node
|
||||
// Starts the real server (server/index.js) on a spare port with a throwaway data folder and walks the whole API
|
||||
// the way the app does: accounts, studies, synced reader data, sharing, co-teaching, admin. It records the status of
|
||||
// every call, so reorganising the server's files can't quietly change what any route answers.
|
||||
import { afterAll, beforeAll, expect, test } from 'vitest';
|
||||
import { spawn } from 'child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { createServer } from 'net';
|
||||
|
||||
const ADMIN = 'admin@x.test';
|
||||
let proc; let dir; let base;
|
||||
const jars = {}; // who -> cookie
|
||||
const log = [];
|
||||
|
||||
const freePort = () => new Promise((resolve) => { const s = createServer(); s.listen(0, () => { const { port } = s.address(); s.close(() => resolve(port)); }); });
|
||||
|
||||
async function call(who, method, path, body, headers = {}) {
|
||||
const res = await fetch(base + path, {
|
||||
method, headers: { 'Content-Type': 'application/json', ...(jars[who] ? { Cookie: jars[who] } : {}), ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual',
|
||||
});
|
||||
const set = res.headers.get('set-cookie');
|
||||
if (set) jars[who] = set.split(';')[0];
|
||||
const text = await res.text();
|
||||
let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ }
|
||||
log.push(`${who} ${method} ${path.replace(/[0-9a-f]{20,}/g, ':token').replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, ':id').replace(/[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}/g, ':code')} ${res.status}`);
|
||||
return { status: res.status, json, text };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'smoke-'));
|
||||
const port = await freePort();
|
||||
base = `http://127.0.0.1:${port}`;
|
||||
proc = spawn('node', ['server/index.js'], {
|
||||
env: { ...process.env, PORT: String(port), DATA_DIR: dir, ADMIN_EMAIL: ADMIN, SESSION_SECRET: 'smoke', BACKUP_DISABLED: '1', NODE_ENV: 'development', IQBIBLE_API_KEY: '' },
|
||||
stdio: 'ignore',
|
||||
});
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
try { if ((await fetch(`${base}/api/health`)).ok) return; } catch { /* not up yet */ }
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
throw new Error('server did not start');
|
||||
}, 20000);
|
||||
afterAll(() => { proc?.kill(); rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
const study = (id, title = 'Romans') => ({ id, title, translation: 'BSB', lastEdited: 1, chapters: [{ book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [{ number: 1, text: 'x' }], chunks: [] }] });
|
||||
|
||||
test('the whole API answers the way it always has', async () => {
|
||||
// ---- public ----
|
||||
expect((await call('anon', 'GET', '/api/health')).status).toBe(200);
|
||||
expect((await call('anon', 'GET', '/api/auth/signup-mode')).json.mode).toBe('open');
|
||||
expect((await call('anon', 'GET', '/api/auth/me')).status).toBe(401);
|
||||
expect((await call('anon', 'GET', '/api/projects')).status).toBe(401);
|
||||
expect((await call('anon', 'GET', '/api/admin/users')).status).toBe(401);
|
||||
|
||||
// ---- accounts ----
|
||||
expect((await call('admin', 'POST', '/api/auth/register', { email: ADMIN, password: 'correct-horse-1' })).status).toBe(200);
|
||||
expect((await call('sam', 'POST', '/api/auth/register', { email: 'sam@x.test', password: 'correct-horse-2' })).status).toBe(200);
|
||||
expect((await call('anon', 'POST', '/api/auth/register', { email: 'bad', password: 'x' })).status).toBe(400);
|
||||
expect((await call('anon', 'POST', '/api/auth/login', { email: ADMIN, password: 'wrong-password' })).status).toBe(401);
|
||||
expect((await call('admin', 'POST', '/api/auth/login', { email: ADMIN, password: 'correct-horse-1' })).status).toBe(200);
|
||||
const me = await call('admin', 'GET', '/api/auth/me');
|
||||
expect(me.json).toMatchObject({ email: ADMIN, isAdmin: true, totpEnabled: false, passkeyCount: 0 });
|
||||
expect((await call('sam', 'GET', '/api/auth/me')).json.isAdmin).toBe(false);
|
||||
expect((await call('anon', 'POST', '/api/auth/forgot', { email: 'sam@x.test' })).json.ok).toBe(true);
|
||||
expect((await call('anon', 'POST', '/api/auth/reset', { email: 'sam@x.test', code: 'nope', newPassword: 'another-pass-3' })).status).toBe(400);
|
||||
expect((await call('admin', 'PATCH', '/api/auth/profile', { podcastName: 'My Show' })).status).toBe(200);
|
||||
expect((await call('admin', 'PATCH', '/api/auth/reminder-settings', { reminderEnabled: true, ntfyTopic: '', reminderTime: '08:00' })).status).toBe(400);
|
||||
expect((await call('admin', 'POST', '/api/auth/reminder-test', { ntfyTopic: 'bad topic!' })).status).toBe(400);
|
||||
expect((await call('admin', 'POST', '/api/auth/change-password', { currentPassword: 'nope', newPassword: 'brand-new-pass-4' })).status).toBe(401);
|
||||
expect((await call('admin', 'POST', '/api/auth/mfa/setup')).json.secret).toBeTruthy();
|
||||
expect((await call('admin', 'POST', '/api/auth/mfa/enable', { token: '000000' })).status).toBe(401);
|
||||
expect((await call('anon', 'POST', '/api/auth/mfa/verify', { token: '000000' })).status).toBe(400);
|
||||
expect((await call('admin', 'GET', '/api/account/export')).json.app).toBeTruthy();
|
||||
|
||||
// ---- studies ----
|
||||
expect((await call('admin', 'PUT', '/api/projects/s1', study('s1'))).status).toBe(200);
|
||||
expect((await call('admin', 'PUT', '/api/projects/s1', study('other'))).status).toBe(400);
|
||||
expect((await call('admin', 'PUT', '/api/projects/s1', { id: 's1' })).status).toBe(400);
|
||||
expect((await call('admin', 'GET', '/api/projects')).json).toHaveLength(1);
|
||||
expect((await call('admin', 'GET', '/api/projects/s1')).json.title).toBe('Romans');
|
||||
expect((await call('sam', 'GET', '/api/projects/s1')).status).toBe(404);
|
||||
expect((await call('admin', 'PUT', '/api/projects/s1', { ...study('s1', 'Romans 8'), lastEdited: 2 }, { 'X-Base-Edited': '1' })).status).toBe(409); // another device saved since
|
||||
expect((await call('admin', 'GET', '/api/projects/s1/versions')).json.versions).toBeInstanceOf(Array);
|
||||
expect((await call('admin', 'GET', '/api/projects/s1/versions/999')).status).toBe(404);
|
||||
expect((await call('admin', 'POST', '/api/projects/s1/versions/999/restore')).status).toBe(404);
|
||||
expect((await call('sam', 'GET', '/api/projects/s1/versions')).status).toBe(404);
|
||||
|
||||
// ---- synced reader data ----
|
||||
expect((await call('admin', 'PUT', '/api/reader/ink/ROM/8', { strokes: [{ p: 1 }] })).status).toBe(200);
|
||||
expect((await call('admin', 'PUT', '/api/reader/ink/ROM/8', { strokes: 'no' })).status).toBe(400);
|
||||
expect((await call('admin', 'PUT', '/api/reader/ink/ROMANS/8', { strokes: [] })).status).toBe(400);
|
||||
expect(Object.keys((await call('admin', 'GET', '/api/reader/ink')).json)).toHaveLength(1);
|
||||
expect((await call('admin', 'PUT', '/api/prefs', { prefs: { theme: { v: 'dark', at: 5 } } })).json.prefs.theme.v).toBe('dark');
|
||||
expect((await call('admin', 'GET', '/api/prefs')).json.prefs.theme.v).toBe('dark');
|
||||
expect((await call('admin', 'PUT', '/api/reader/marks', { bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [] })).json.ok).toBe(true);
|
||||
expect((await call('admin', 'GET', '/api/reader/marks')).json.bookmarks['JHN-3-16']).toBe('#fef08a');
|
||||
expect((await call('admin', 'PUT', '/api/reader/marks', 'x')).status).toBe(400);
|
||||
expect((await call('admin', 'PUT', '/api/prayers', { prayers: [{ id: 'p1', text: 'Peace', createdAt: 1, updatedAt: 2 }] })).json.prayers).toHaveLength(1);
|
||||
expect((await call('admin', 'GET', '/api/prayers')).json.prayers).toHaveLength(1);
|
||||
expect((await call('admin', 'PUT', '/api/reader/history', { 'JHN-3': 1 })).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/reader/history')).status).toBe(200);
|
||||
expect((await call('admin', 'PUT', '/api/reading-plans', { progress: [{ id: 'r1' }], customPlans: [], activity: ['2026-10-01'] })).json.ok).toBe(true);
|
||||
expect((await call('admin', 'PUT', '/api/reading-plans', { progress: 'no' })).status).toBe(400);
|
||||
expect((await call('admin', 'GET', '/api/reading-plans')).json.progress).toHaveLength(1);
|
||||
|
||||
// ---- other areas mounted on the app ----
|
||||
expect((await call('admin', 'GET', '/api/iq/status')).json.available).toBe(false);
|
||||
expect((await call('admin', 'GET', '/api/iq/nope')).status).toBe(404);
|
||||
expect((await call('admin', 'GET', '/api/passkeys')).json).toEqual([]);
|
||||
expect((await call('admin', 'GET', '/api/sessions')).json).toHaveLength(1);
|
||||
expect((await call('admin', 'POST', '/api/auth/passkey/options')).json.challenge).toBeTruthy();
|
||||
expect((await call('anon', 'GET', '/api/bible/nope')).status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
// ---- sharing ----
|
||||
const share = await call('admin', 'POST', '/api/projects/s1/share', {});
|
||||
expect(share.status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/projects/s1/share')).status).toBe(200);
|
||||
expect((await call('anon', 'GET', `/api/share/${share.json.shareToken}`)).status).toBe(200);
|
||||
expect((await call('anon', 'GET', '/api/share/doesnotexist')).status).toBe(404);
|
||||
const outline = await call('admin', 'POST', '/api/projects/s1/outline-share', {});
|
||||
expect((await call('anon', 'GET', `/api/share-outline/${outline.json.shareToken}`)).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/projects/s1/outline-share')).status).toBe(200);
|
||||
expect((await call('admin', 'DELETE', '/api/projects/s1/outline-share')).status).toBe(200);
|
||||
expect((await call('anon', 'GET', `/api/share-outline/${outline.json.shareToken}`)).status).toBe(404);
|
||||
expect((await call('admin', 'DELETE', '/api/projects/s1/share')).status).toBe(200);
|
||||
const invite = await call('admin', 'POST', '/api/projects/s1/collaborators/invite');
|
||||
expect(invite.status).toBe(200);
|
||||
expect((await call('sam', 'POST', `/api/join/${invite.json.token}`)).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/projects/s1/collaborators')).status).toBe(200);
|
||||
expect((await call('sam', 'GET', '/api/projects/s1/versions')).status).toBe(200);
|
||||
expect((await call('admin', 'DELETE', `/api/projects/s1/collaborators/${me.json.id}`)).status).toBe(200);
|
||||
|
||||
// ---- admin ----
|
||||
expect((await call('sam', 'GET', '/api/admin/users')).status).toBe(403);
|
||||
expect((await call('admin', 'GET', '/api/admin/users')).json).toHaveLength(2);
|
||||
expect((await call('admin', 'GET', '/api/admin/projects')).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/admin/projects/s1')).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/admin/signup')).json.mode).toBe('open');
|
||||
expect((await call('admin', 'PUT', '/api/admin/signup', { mode: 'nonsense' })).status).toBe(400);
|
||||
expect((await call('admin', 'PUT', '/api/admin/signup', { mode: 'invite' })).status).toBe(200);
|
||||
const code = await call('admin', 'POST', '/api/admin/invites', { note: 'x', days: 7 });
|
||||
expect(code.status).toBe(200);
|
||||
expect((await call('admin', 'DELETE', `/api/admin/invites/${encodeURIComponent(code.json.code ?? 'none')}`)).status).toBeGreaterThanOrEqual(200);
|
||||
const health = await call('admin', 'GET', '/api/admin/health');
|
||||
expect(health.json).toMatchObject({ iqConfigured: false });
|
||||
expect(health.json.signinFailures.length).toBeGreaterThan(0); // the wrong password above
|
||||
expect((await call('admin', 'POST', '/api/admin/backups')).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/admin/backups/nope.db')).status).toBe(404);
|
||||
expect((await call('admin', 'POST', '/api/admin/backups/projects-2000-01-01-00-00.db/restore', { confirm: 'x' })).status).toBe(404);
|
||||
const samId = (await call('sam', 'GET', '/api/auth/me')).json.id;
|
||||
expect((await call('admin', 'POST', `/api/admin/users/${samId}/reset-password`)).json.temporaryPassword).toBeTruthy();
|
||||
expect((await call('sam', 'GET', '/api/auth/me')).status).toBe(401); // signed out everywhere
|
||||
expect((await call('admin', 'DELETE', `/api/admin/users/${samId}`)).status).toBe(200);
|
||||
|
||||
// ---- finishing ----
|
||||
expect((await call('admin', 'DELETE', '/api/projects/s1')).status).toBe(200);
|
||||
expect((await call('admin', 'POST', '/api/auth/logout')).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/auth/me')).status).toBe(401);
|
||||
|
||||
// Every call and its status, in order. Run with UPDATE_SMOKE=1 to rewrite it after a deliberate API change.
|
||||
const file = new URL('./routes.smoke.expected.txt', import.meta.url);
|
||||
if (process.env.UPDATE_SMOKE === '1') writeFileSync(file, `${log.join('\n')}\n`);
|
||||
expect(`${log.join('\n')}\n`).toBe(readFileSync(file, 'utf8'));
|
||||
}, 60000);
|
||||
@@ -0,0 +1,133 @@
|
||||
// Account settings: profile, reminder settings, test notification, and the full data export.
|
||||
import { getAllProjects, getAllReaderInk, getPrayers, getProject, getReaderHistory, getReaderMarks, getReadingProgress, getUserById, getUserPrefs, setPodcastName, setPrayerReminder, setReminderSettings, setVotdSettings } from '../db.js';
|
||||
import { requireAuth } from '../auth.js';
|
||||
import { votdAvailable } from '../votd.js';
|
||||
import { assertPublicUrl, isValidTimeZone, sendTestReminder } from '../reminders.js';
|
||||
import { accountExportName, buildAccountExport } from '../accountExport.js';
|
||||
|
||||
export function registerAccountRoutes(app) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /api/auth/profile — update account-level settings (currently just podcastName)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.patch('/api/auth/profile', requireAuth, (req, res) => {
|
||||
try {
|
||||
const podcastName = String(req.body?.podcastName ?? '').trim().slice(0, 200);
|
||||
setPodcastName(req.session.userId, podcastName || null);
|
||||
res.json({ podcastName: podcastName || null });
|
||||
} catch (err) {
|
||||
console.error('PATCH /api/auth/profile error:', err);
|
||||
res.status(500).json({ error: 'Failed to save profile.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /api/auth/reminder-settings — daily reading reminder via ntfy.sh
|
||||
// ---------------------------------------------------------------------------
|
||||
app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const reminderEnabled = !!req.body?.reminderEnabled;
|
||||
const ntfyTopic = String(req.body?.ntfyTopic ?? '').trim();
|
||||
const ntfyUrl = String(req.body?.ntfyUrl ?? '').trim();
|
||||
const reminderTime = String(req.body?.reminderTime ?? '').trim();
|
||||
const reminderTz = isValidTimeZone(req.body?.reminderTz) ? String(req.body.reminderTz) : null;
|
||||
|
||||
if (ntfyTopic && !/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
|
||||
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
|
||||
}
|
||||
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
|
||||
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
|
||||
}
|
||||
if (ntfyUrl) {
|
||||
try { await assertPublicUrl(ntfyUrl); } catch (err) { return res.status(400).json({ error: err.message }); }
|
||||
}
|
||||
if (reminderEnabled) {
|
||||
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to enable reminders.' });
|
||||
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(reminderTime)) {
|
||||
return res.status(400).json({ error: 'Reminder time must be in HH:MM format.' });
|
||||
}
|
||||
}
|
||||
|
||||
// the verse of the day is optional, and only offered when the server can look it up
|
||||
const votdEnabled = !!req.body?.votdEnabled && votdAvailable();
|
||||
const votdTime = String(req.body?.votdTime ?? '').trim();
|
||||
if (votdEnabled) {
|
||||
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the verse of the day.' });
|
||||
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(votdTime)) return res.status(400).json({ error: 'The verse of the day time must be in HH:MM format.' });
|
||||
}
|
||||
setVotdSettings(req.session.userId, { votdEnabled, votdTime: votdTime || null });
|
||||
const prayerEnabled = !!req.body?.prayerEnabled;
|
||||
const prayerTime = String(req.body?.prayerTime ?? '').trim();
|
||||
if (prayerEnabled) {
|
||||
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the prayer reminder.' });
|
||||
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(prayerTime)) return res.status(400).json({ error: 'The prayer reminder time must be in HH:MM format.' });
|
||||
}
|
||||
setPrayerReminder(req.session.userId, { prayerEnabled, prayerTime: prayerTime || null });
|
||||
setReminderSettings(req.session.userId, {
|
||||
ntfyUrl: ntfyUrl || null,
|
||||
ntfyTopic: ntfyTopic || null,
|
||||
reminderTime: reminderTime || null,
|
||||
reminderEnabled,
|
||||
reminderTz,
|
||||
});
|
||||
res.json({
|
||||
reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null,
|
||||
votdEnabled, votdTime: votdTime || null, prayerEnabled, prayerTime: prayerTime || null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('PATCH /api/auth/reminder-settings error:', err);
|
||||
res.status(500).json({ error: 'Failed to save reminder settings.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/account/export — everything this account holds, as one restorable file
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/account/export', requireAuth, (req, res) => {
|
||||
try {
|
||||
const userId = req.session.userId;
|
||||
const user = getUserById(userId);
|
||||
const backup = buildAccountExport(user, {
|
||||
projects: () => getAllProjects(userId).map((p) => getProject(p.id, userId)).filter(Boolean),
|
||||
prefs: () => getUserPrefs(userId),
|
||||
marks: () => getReaderMarks(userId),
|
||||
ink: () => getAllReaderInk(userId),
|
||||
plans: () => getReadingProgress(userId),
|
||||
history: () => getReaderHistory(userId),
|
||||
prayers: () => getPrayers(userId).filter((p) => !p.deleted),
|
||||
});
|
||||
res.set('Content-Disposition', `attachment; filename="${accountExportName()}"`);
|
||||
res.json(backup);
|
||||
} catch (err) {
|
||||
console.error('GET /api/account/export error:', err);
|
||||
res.status(500).json({ error: 'Failed to export your data.' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/reminder-test — sends a test notification to the topic in the request
|
||||
// body (so it can be tried before saving), or to the saved settings.
|
||||
const _lastReminderTest = new Map(); // userId -> timestamp, to keep test sends from being spammed
|
||||
app.post('/api/auth/reminder-test', requireAuth, async (req, res) => {
|
||||
const userId = req.session.userId;
|
||||
if (Date.now() - (_lastReminderTest.get(userId) ?? 0) < 5000) {
|
||||
return res.status(429).json({ error: 'Wait a few seconds before sending another test.' });
|
||||
}
|
||||
_lastReminderTest.set(userId, Date.now());
|
||||
const saved = getUserById(userId);
|
||||
const ntfyTopic = String(req.body?.ntfyTopic ?? saved?.ntfyTopic ?? '').trim();
|
||||
const ntfyUrl = String(req.body?.ntfyUrl ?? saved?.ntfyUrl ?? '').trim();
|
||||
if (!ntfyTopic) return res.status(400).json({ error: 'Enter a topic first.' });
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
|
||||
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
|
||||
}
|
||||
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
|
||||
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
|
||||
}
|
||||
try {
|
||||
await sendTestReminder({ ntfyTopic, ntfyUrl });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Admin only: users, studies, sign-up rules and invites, server health and snapshots.
|
||||
import { adminDeleteProject, adminDeleteUser, adminGetAllProjects, adminGetAllUsers, adminGetProject, createInvite, dataDir, deleteInvite, destroyAllSessionsForUser, getSetting, getUserById, listInvites, setSetting, setUserPassword } from '../db.js';
|
||||
import { generateTemporaryPassword, hashPassword, requireAdmin, requireAuth } from '../auth.js';
|
||||
import { SIGNUP_MODES, generateInviteCode, isSignupMode, normalizeInviteCode } from '../signup.js';
|
||||
import { restoreSnapshot, runBackup, snapshotPath } from '../backups.js';
|
||||
import { backupStatus, diskUse, logEvent, recentEvents, uptimeSeconds } from '../health.js';
|
||||
|
||||
export function registerAdminRoutes(app) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin — restricted to the single designated admin account (see ADMIN_EMAIL
|
||||
// in server/auth.js). Full visibility/control over every user and project.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.get('/api/admin/users', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
res.json(adminGetAllUsers());
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/users error:', err);
|
||||
res.status(500).json({ error: 'Failed to list users.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
if (req.params.id === req.session.userId) {
|
||||
return res.status(400).json({ error: "Can't delete your own admin account." });
|
||||
}
|
||||
adminDeleteUser(req.params.id);
|
||||
destroyAllSessionsForUser(req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/admin/users/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete user.' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/admin/users/:id/reset-password — sets a random temporary password and
|
||||
// signs the user out everywhere, since there's no self-service "forgot password" flow yet.
|
||||
app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const temporaryPassword = generateTemporaryPassword();
|
||||
const passwordHash = await hashPassword(temporaryPassword);
|
||||
setUserPassword(req.params.id, passwordHash);
|
||||
destroyAllSessionsForUser(req.params.id);
|
||||
res.json({ temporaryPassword });
|
||||
} catch (err) {
|
||||
console.error('POST /api/admin/users/:id/reset-password error:', err);
|
||||
res.status(500).json({ error: 'Failed to reset password.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Who may sign up, and the invite codes for it
|
||||
// Server health: disk use, snapshots, and a short log of failures (in memory; resets on restart).
|
||||
app.get('/api/admin/health', requireAuth, requireAdmin, (_req, res) => {
|
||||
try {
|
||||
const emailOf = (id) => (id ? getUserById(id)?.email ?? null : null);
|
||||
const withUser = (e) => ({ ...e, email: emailOf(e.userId) });
|
||||
res.json({
|
||||
uptimeSeconds: uptimeSeconds(), node: process.version, dataDir: dataDir(),
|
||||
disk: diskUse(), backups: backupStatus(),
|
||||
iqConfigured: !!process.env.IQBIBLE_API_KEY,
|
||||
signinFailures: recentEvents('signin', 30).map(withUser),
|
||||
notifyFailures: recentEvents('notify', 30).map(withUser),
|
||||
backupFailures: recentEvents('backup', 10),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/health error:', err);
|
||||
res.status(500).json({ error: 'Failed to read server health.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/backups', requireAuth, requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
await runBackup();
|
||||
res.json(backupStatus());
|
||||
} catch (err) {
|
||||
console.error('POST /api/admin/backups error:', err);
|
||||
logEvent('backup', err.message);
|
||||
res.status(500).json({ error: 'The snapshot could not be made.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/backups/:name', requireAuth, requireAdmin, (req, res) => {
|
||||
const file = snapshotPath(req.params.name);
|
||||
if (!file) return res.status(404).json({ error: 'No such snapshot.' });
|
||||
res.download(file, req.params.name);
|
||||
});
|
||||
|
||||
// Restoring replaces every account's data, so the snapshot's name has to be typed back as confirmation.
|
||||
app.post('/api/admin/backups/:name/restore', requireAuth, requireAdmin, async (req, res) => {
|
||||
if (!snapshotPath(req.params.name)) return res.status(404).json({ error: 'No such snapshot.' });
|
||||
if (req.body?.confirm !== req.params.name) return res.status(400).json({ error: 'Type the snapshot’s name to confirm.' });
|
||||
try {
|
||||
const result = await restoreSnapshot(req.params.name);
|
||||
res.json({ restored: result.restored, safetyCopy: result.safetyCopy.split('/').pop() });
|
||||
} catch (err) {
|
||||
console.error('POST /api/admin/backups/:name/restore error:', err);
|
||||
res.status(500).json({ error: 'The snapshot could not be restored.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/signup', requireAuth, requireAdmin, (_req, res) => {
|
||||
res.json({ mode: getSetting('signup', 'open'), invites: listInvites() });
|
||||
});
|
||||
|
||||
app.put('/api/admin/signup', requireAuth, requireAdmin, (req, res) => {
|
||||
if (!isSignupMode(req.body?.mode)) return res.status(400).json({ error: `Mode must be one of: ${SIGNUP_MODES.join(', ')}.` });
|
||||
setSetting('signup', req.body.mode);
|
||||
res.json({ mode: req.body.mode });
|
||||
});
|
||||
|
||||
app.post('/api/admin/invites', requireAuth, requireAdmin, (req, res) => {
|
||||
const days = Math.min(90, Math.max(1, Number(req.body?.days) || 7));
|
||||
const code = generateInviteCode();
|
||||
createInvite(code, String(req.body?.note ?? '').trim().slice(0, 80), Date.now() + days * 86_400_000);
|
||||
res.json({ code, expiresInDays: days });
|
||||
});
|
||||
|
||||
app.delete('/api/admin/invites/:code', requireAuth, requireAdmin, (req, res) => {
|
||||
deleteInvite(normalizeInviteCode(req.params.code));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
res.json(adminGetAllProjects());
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/projects error:', err);
|
||||
res.status(500).json({ error: 'Failed to list projects.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const project = adminGetProject(req.params.id);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json(project);
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to load project.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
adminDeleteProject(req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/admin/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete project.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// Accounts: sign up, sign in (password + two-factor), password reset, change password, two-factor setup.
|
||||
import QRCode from 'qrcode';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { adminDeleteUser, bumpPasswordResetAttempts, claimOrphanProjects, clearPasswordReset, countUsers, createUser, destroyAllSessionsForUser, disableTotp, enableTotp, findOpenInvite, getPasswordReset, getSetting, getUserByEmail, getUserById, setBackupCodeHashes, setPasswordReset, setUserPassword, useInvite } from '../db.js';
|
||||
import { consumeBackupCode, generateBackupCodes, generateResetCode, generateTotpSecret, hashBackupCodes, hashPassword, isAdminEmail, isValidEmail, isValidPassword, normalizeResetCode, requireAuth, totpKeyUri, verifyPassword, verifyTotpToken } from '../auth.js';
|
||||
import { sendNtfy } from '../reminders.js';
|
||||
import { noteSignIn } from '../devices.js';
|
||||
import { checkSignup, normalizeInviteCode } from '../signup.js';
|
||||
import { logEvent } from '../health.js';
|
||||
import { userPayload } from '../userPayload.js';
|
||||
import { TOO_MANY, clearFailures, isLimited, recordFailure } from '../limiter.js';
|
||||
|
||||
export function registerAuthRoutes(app) {
|
||||
// Compared against when an email isn't registered, so a login takes the same time either way.
|
||||
const DUMMY_HASH = '$2b$10$UbGSJvxosy5oInS6zfvqM.gWOUv9ypVg81ou.jWOswHyYgUjpkhJK';
|
||||
|
||||
// TOTP codes already used (userId -> code), so a captured code can't be replayed inside its time window.
|
||||
const _usedTotp = new Map();
|
||||
function totpAlreadyUsed(userId, token) { return _usedTotp.get(userId) === token; }
|
||||
function markTotpUsed(userId, token) {
|
||||
_usedTotp.set(userId, token);
|
||||
setTimeout(() => { if (_usedTotp.get(userId) === token) _usedTotp.delete(userId); }, 90 * 1000).unref();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.post('/api/auth/register', async (req, res) => {
|
||||
try {
|
||||
if (isLimited(`ip:${req.ip}`)) return res.status(429).json(TOO_MANY);
|
||||
recordFailure(`ip:${req.ip}`); // every registration attempt counts
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
const password = String(req.body?.password ?? '');
|
||||
|
||||
const inviteCode = normalizeInviteCode(req.body?.invite);
|
||||
const allowed = checkSignup({ mode: getSetting('signup', 'open'), userCount: countUsers(), inviteOk: !!findOpenInvite(inviteCode) });
|
||||
if (!allowed.ok) return res.status(allowed.status).json({ error: allowed.error });
|
||||
if (!isValidEmail(email)) {
|
||||
return res.status(400).json({ error: 'Enter a valid email address.' });
|
||||
}
|
||||
if (!isValidPassword(password)) {
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters.' });
|
||||
}
|
||||
if (getUserByEmail(email)) {
|
||||
return res.status(409).json({ error: 'An account with that email already exists.' });
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const user = createUser({ id: randomUUID(), email, passwordHash });
|
||||
if (getSetting('signup', 'open') === 'invite' && countUsers() > 1 && !useInvite(inviteCode, user.id)) {
|
||||
adminDeleteUser(user.id); // someone used the code between the check and now
|
||||
return res.status(403).json({ error: 'That invite code was just used. Ask for a new one.' });
|
||||
}
|
||||
|
||||
// The very first account inherits any projects created before multi-user support existed.
|
||||
if (countUsers() === 1) {
|
||||
claimOrphanProjects(user.id);
|
||||
}
|
||||
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
req.session.userId = user.id;
|
||||
noteSignIn(req, user, 'register');
|
||||
res.json({
|
||||
id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email),
|
||||
reminderEnabled: false, ntfyUrl: null, ntfyTopic: null, reminderTime: null,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/register error:', err);
|
||||
res.status(500).json({ error: 'Failed to register.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', async (req, res) => {
|
||||
try {
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
const password = String(req.body?.password ?? '');
|
||||
const keys = [`ip:${req.ip}`, `acct:${email}`];
|
||||
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||||
|
||||
const user = getUserByEmail(email);
|
||||
const valid = await verifyPassword(password, user?.passwordHash ?? DUMMY_HASH) && !!user;
|
||||
if (!valid) {
|
||||
recordFailure(...keys);
|
||||
logEvent('signin', `Wrong password for ${email.slice(0, 80) || '(blank)'} from ${req.ip}`);
|
||||
return res.status(401).json({ error: 'Incorrect email or password.' });
|
||||
}
|
||||
clearFailures(`acct:${email}`);
|
||||
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
if (user.totpEnabled) {
|
||||
// Password is correct, but the session stays unauthenticated (no userId)
|
||||
// until a valid TOTP/backup code lands on /api/auth/mfa/verify.
|
||||
req.session.pendingUserId = user.id;
|
||||
return res.json({ mfaRequired: true });
|
||||
}
|
||||
req.session.userId = user.id;
|
||||
noteSignIn(req, user, 'password');
|
||||
res.json(userPayload(user));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/login error:', err);
|
||||
res.status(500).json({ error: 'Failed to log in.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/mfa/verify', async (req, res) => {
|
||||
try {
|
||||
const pendingUserId = req.session?.pendingUserId;
|
||||
if (!pendingUserId) {
|
||||
return res.status(400).json({ error: 'No sign-in in progress.' });
|
||||
}
|
||||
const keys = [`ip:${req.ip}`, `mfa:${pendingUserId}`];
|
||||
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||||
const user = getUserById(pendingUserId);
|
||||
if (!user || !user.totpEnabled) {
|
||||
return res.status(400).json({ error: 'No sign-in in progress.' });
|
||||
}
|
||||
|
||||
const token = req.body?.token;
|
||||
const backupCode = req.body?.backupCode;
|
||||
let ok = token ? verifyTotpToken(String(token), user.totpSecret) && !totpAlreadyUsed(user.id, String(token)) : false;
|
||||
if (ok) markTotpUsed(user.id, String(token));
|
||||
|
||||
if (!ok && backupCode) {
|
||||
const remaining = await consumeBackupCode(String(backupCode), user.backupCodeHashes);
|
||||
if (remaining) {
|
||||
setBackupCodeHashes(user.id, remaining);
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
recordFailure(...keys);
|
||||
logEvent('signin', `Wrong two-factor code for ${user.email} from ${req.ip}`, { userId: user.id });
|
||||
return res.status(401).json({ error: 'Invalid code.' });
|
||||
}
|
||||
clearFailures(`mfa:${pendingUserId}`);
|
||||
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
req.session.userId = user.id;
|
||||
noteSignIn(req, user, 'mfa');
|
||||
res.json(userPayload(user));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/verify error:', err);
|
||||
res.status(500).json({ error: 'Failed to verify code.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Forgot password. There is no email here, so the one-time code goes to the account's ntfy topic (the same
|
||||
// one used for reading reminders). Accounts without a topic still go through the admin. The answer is the
|
||||
// same whether or not the account exists, so this can't be used to find out who has one.
|
||||
// ---------------------------------------------------------------------------
|
||||
const RESET_MINUTES = 15;
|
||||
const RESET_REPLY = { ok: true, message: 'If that account has a notification topic set up, a reset code was just sent to it. Codes last 15 minutes. No topic? Ask the administrator for a temporary password.' };
|
||||
|
||||
app.post('/api/auth/forgot', async (req, res) => {
|
||||
try {
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
const keys = [`ip:${req.ip}`, `forgot:${email}`];
|
||||
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||||
recordFailure(...keys); // every request counts
|
||||
const user = isValidEmail(email) ? getUserByEmail(email) : null;
|
||||
if (user?.ntfyTopic) {
|
||||
const code = generateResetCode();
|
||||
setPasswordReset(user.id, await hashPassword(code), Date.now() + RESET_MINUTES * 60 * 1000);
|
||||
sendNtfy(user, { title: 'Password reset code', body: `Your Bible Study reset code is ${code}. It works for ${RESET_MINUTES} minutes. If you didn't ask for it, ignore this.`, tags: 'key' })
|
||||
.catch((err) => console.error('Password reset notification failed:', err.message));
|
||||
}
|
||||
res.json(RESET_REPLY);
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/forgot error:', err);
|
||||
res.status(500).json({ error: 'Failed to start the reset.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/reset', async (req, res) => {
|
||||
try {
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
const keys = [`ip:${req.ip}`, `reset:${email}`];
|
||||
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||||
const fail = (error) => { recordFailure(...keys); return res.status(400).json({ error }); };
|
||||
const newPassword = String(req.body?.newPassword ?? '');
|
||||
if (!isValidPassword(newPassword)) return res.status(400).json({ error: 'New password must be at least 8 characters.' });
|
||||
|
||||
const user = getUserByEmail(email);
|
||||
const reset = user ? getPasswordReset(user.id) : null;
|
||||
const code = normalizeResetCode(req.body?.code);
|
||||
// Always spend one bcrypt compare, so timing doesn't show whether a code was issued
|
||||
const codeOk = await verifyPassword(code, reset?.codeHash ?? DUMMY_HASH) && !!reset && reset.attempts < 5;
|
||||
if (reset && !codeOk) bumpPasswordResetAttempts(user.id);
|
||||
if (!codeOk) return fail('That code is wrong or has expired. Ask for a new one.');
|
||||
if (user.totpEnabled) {
|
||||
const token = String(req.body?.token ?? '');
|
||||
if (!verifyTotpToken(token, user.totpSecret) || totpAlreadyUsed(user.id, token)) return fail('Enter the current 6-digit code from your authenticator app too.');
|
||||
markTotpUsed(user.id, token);
|
||||
}
|
||||
setUserPassword(user.id, await hashPassword(newPassword));
|
||||
clearPasswordReset(user.id);
|
||||
destroyAllSessionsForUser(user.id);
|
||||
clearFailures(...keys);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/reset error:', err);
|
||||
res.status(500).json({ error: 'Failed to reset the password.' });
|
||||
}
|
||||
});
|
||||
|
||||
// What the sign-in page needs to know: whether to offer "create an account", and whether it needs an invite code.
|
||||
app.get('/api/auth/signup-mode', (_req, res) => {
|
||||
res.json({ mode: countUsers() === 0 ? 'open' : getSetting('signup', 'open') });
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
res.clearCookie('connect.sid');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', (req, res) => {
|
||||
const user = req.session?.userId ? getUserById(req.session.userId) : null;
|
||||
if (!user) return res.status(401).json({ error: 'Not signed in.' });
|
||||
res.json(userPayload(user));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/auth/change-password — self-service password change (Account Settings)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post('/api/auth/change-password', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.session.userId);
|
||||
const currentPassword = String(req.body?.currentPassword ?? '');
|
||||
const newPassword = String(req.body?.newPassword ?? '');
|
||||
|
||||
if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY);
|
||||
const valid = await verifyPassword(currentPassword, user.passwordHash);
|
||||
if (!valid) {
|
||||
recordFailure(`pw:${user.id}`);
|
||||
return res.status(401).json({ error: 'Current password is incorrect.' });
|
||||
}
|
||||
if (!isValidPassword(newPassword)) {
|
||||
return res.status(400).json({ error: 'New password must be at least 8 characters.' });
|
||||
}
|
||||
|
||||
clearFailures(`pw:${user.id}`);
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
setUserPassword(user.id, passwordHash);
|
||||
// Sign out every other session (e.g. a stolen one) but keep this one logged in.
|
||||
destroyAllSessionsForUser(user.id, req.sessionID);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/change-password error:', err);
|
||||
res.status(500).json({ error: 'Failed to change password.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Two-factor auth setup (requires an already-authenticated session)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.post('/api/auth/mfa/setup', requireAuth, (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.session.userId);
|
||||
const secret = generateTotpSecret();
|
||||
// Held only in the session until confirmed with a real code — never written
|
||||
// to the DB (and 2FA never turned on) unless /mfa/enable succeeds below.
|
||||
req.session.pendingTotpSecret = secret;
|
||||
QRCode.toDataURL(totpKeyUri(user.email, secret), (err, qrCodeDataUrl) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to generate QR code.' });
|
||||
res.json({ secret, qrCodeDataUrl });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/setup error:', err);
|
||||
res.status(500).json({ error: 'Failed to start 2FA setup.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/mfa/enable', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const secret = req.session.pendingTotpSecret;
|
||||
if (!secret) {
|
||||
return res.status(400).json({ error: 'Start 2FA setup first.' });
|
||||
}
|
||||
if (isLimited(`mfaset:${req.session.userId}`)) return res.status(429).json(TOO_MANY);
|
||||
if (!verifyTotpToken(String(req.body?.token ?? ''), secret)) {
|
||||
recordFailure(`mfaset:${req.session.userId}`);
|
||||
return res.status(401).json({ error: 'That code didn\'t match. Check your authenticator app and try again.' });
|
||||
}
|
||||
|
||||
const backupCodes = generateBackupCodes();
|
||||
const backupCodeHashes = await hashBackupCodes(backupCodes);
|
||||
enableTotp(req.session.userId, secret, backupCodeHashes);
|
||||
delete req.session.pendingTotpSecret;
|
||||
res.json({ backupCodes });
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/enable error:', err);
|
||||
res.status(500).json({ error: 'Failed to enable 2FA.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/mfa/disable', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.session.userId);
|
||||
if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY);
|
||||
const valid = await verifyPassword(String(req.body?.password ?? ''), user.passwordHash);
|
||||
if (!valid) {
|
||||
recordFailure(`pw:${user.id}`);
|
||||
return res.status(401).json({ error: 'Incorrect password.' });
|
||||
}
|
||||
disableTotp(user.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/disable error:', err);
|
||||
res.status(500).json({ error: 'Failed to disable 2FA.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Studies: list, load, save (with conflict checks), earlier versions, delete.
|
||||
import { canEditProject, deleteProject, getAllProjects, getProject, getProjectEdited, getVersion, listVersions, restoreVersion, upsertProject } from '../db.js';
|
||||
import { requireAuth } from '../auth.js';
|
||||
import { isSaveConflict, parseBase } from '../projectSync.js';
|
||||
|
||||
export function registerProjectRoutes(app) {
|
||||
// GET /api/projects — list all project summaries owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/projects', requireAuth, (req, res) => {
|
||||
try {
|
||||
const projects = getAllProjects(req.session.userId);
|
||||
res.json(projects);
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects error:', err);
|
||||
res.status(500).json({ error: 'Failed to list projects.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/projects/:id — fetch a single full project owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/projects/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const project = getProject(req.params.id, req.session.userId);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json(project);
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to load project.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /api/projects/:id — create or update a project owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.put('/api/projects/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const body = req.body;
|
||||
if (!body || typeof body !== 'object') {
|
||||
return res.status(400).json({ error: 'Invalid JSON body.' });
|
||||
}
|
||||
if (!body.id || !body.title || !Array.isArray(body.chapters)) {
|
||||
return res.status(400).json({ error: 'Missing required fields: id, title, chapters.' });
|
||||
}
|
||||
if (body.id !== req.params.id) {
|
||||
return res.status(400).json({ error: 'URL id does not match body id.' });
|
||||
}
|
||||
if (body.id.length > 100) return res.status(400).json({ error: 'Invalid project id.' });
|
||||
if (body.title.length > 500) return res.status(400).json({ error: 'Project title is too long.' });
|
||||
// Refuse to overwrite a copy another device saved after the one this device last saw
|
||||
const stored = getProjectEdited(body.id, req.session.userId);
|
||||
if (isSaveConflict({ stored, base: parseBase(req.get('X-Base-Edited')), force: req.query.force === '1' })) {
|
||||
return res.status(409).json({ error: 'This study was changed on another device.', code: 'conflict', serverEdited: stored });
|
||||
}
|
||||
const saved = upsertProject(body, req.session.userId);
|
||||
if (!saved) {
|
||||
return res.status(403).json({ error: 'That project belongs to a different account.' });
|
||||
}
|
||||
res.json(saved);
|
||||
} catch (err) {
|
||||
console.error('PUT /api/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to save project.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Earlier versions of a study: list, look at one, and put one back (the copy it replaces is kept too)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/projects/:id/versions', requireAuth, (req, res) => {
|
||||
if (!canEditProject(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ versions: listVersions(req.params.id) });
|
||||
});
|
||||
|
||||
app.get('/api/projects/:id/versions/:versionId', requireAuth, (req, res) => {
|
||||
if (!canEditProject(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
|
||||
const version = getVersion(req.params.id, Number(req.params.versionId));
|
||||
if (!version) return res.status(404).json({ error: 'That version is gone.' });
|
||||
res.json(version);
|
||||
});
|
||||
|
||||
app.post('/api/projects/:id/versions/:versionId/restore', requireAuth, (req, res) => {
|
||||
try {
|
||||
const restored = restoreVersion(req.params.id, Number(req.params.versionId), req.session.userId);
|
||||
if (!restored) return res.status(404).json({ error: 'That version is gone.' });
|
||||
res.json(restored);
|
||||
} catch (err) {
|
||||
console.error('POST restore error:', err);
|
||||
res.status(500).json({ error: 'Failed to restore that version.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/projects/:id — remove a project owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete('/api/projects/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
deleteProject(req.params.id, req.session.userId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete project.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Reading data synced between devices: ink, preferences, bookmarks and highlights, prayers, reading history, plan progress.
|
||||
import { getAllReaderInk, getPrayers, getReaderHistory, getReaderMarks, getReadingProgress, getUserPrefs, setPrayers, setReaderHistory, setReaderInkPage, setReaderMarks, setReadingProgress, setUserPrefs } from '../db.js';
|
||||
import { requireAuth } from '../auth.js';
|
||||
import { mergePrayers } from '../prayers.js';
|
||||
import { mergeHistory } from '../readerHistory.js';
|
||||
import { cleanMarks } from '../readerMarks.js';
|
||||
import { cleanPrefsPatch, mergePrefs } from '../prefs.js';
|
||||
|
||||
export function registerReaderRoutes(app) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reader ink — cross-device draw annotations on Bible chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/reader/ink — all saved ink pages for the current user
|
||||
app.get('/api/reader/ink', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json(getAllReaderInk(req.session.userId));
|
||||
} catch (err) {
|
||||
console.error('GET /api/reader/ink error:', err);
|
||||
res.status(500).json({ error: 'Failed to load reader ink.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/reader/ink/:book/:chapter — save (upsert) one page's ink
|
||||
app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { book, chapter } = req.params;
|
||||
const { strokes } = req.body ?? {};
|
||||
if (!/^[1-3A-Za-z]{3}$/.test(book) || !/^\d{1,3}$/.test(chapter)) return res.status(400).json({ error: 'Invalid book or chapter.' });
|
||||
if (!Array.isArray(strokes)) return res.status(400).json({ error: 'strokes must be an array.' });
|
||||
if (strokes.length > 20000) return res.status(413).json({ error: 'Too much ink on one page.' });
|
||||
setReaderInkPage(req.session.userId, book, chapter, strokes);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/reader/ink error:', err);
|
||||
res.status(500).json({ error: 'Failed to save reader ink.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/prefs — the user's synced display preferences ({ prefs: { key: { v, at } } })
|
||||
app.get('/api/prefs', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json({ prefs: getUserPrefs(req.session.userId) });
|
||||
} catch (err) {
|
||||
console.error('GET /api/prefs error:', err);
|
||||
res.status(500).json({ error: 'Failed to load preferences.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/prefs — merge changed preferences in (newest change per key wins); returns the result
|
||||
app.put('/api/prefs', requireAuth, (req, res) => {
|
||||
try {
|
||||
const merged = mergePrefs(getUserPrefs(req.session.userId), cleanPrefsPatch(req.body));
|
||||
setUserPrefs(req.session.userId, merged);
|
||||
res.json({ prefs: merged });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/prefs error:', err);
|
||||
res.status(500).json({ error: 'Failed to save preferences.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/reader/marks — the user's bookmarks + highlights, with an updatedAt to compare against
|
||||
app.get('/api/reader/marks', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json(getReaderMarks(req.session.userId));
|
||||
} catch (err) {
|
||||
console.error('GET /api/reader/marks error:', err);
|
||||
res.status(500).json({ error: 'Failed to load bookmarks and highlights.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/prayers — the prayer journal; PUT merges this device's copy in (newer prayer wins) and returns the whole
|
||||
app.get('/api/prayers', requireAuth, (req, res) => {
|
||||
try { res.json({ prayers: mergePrayers(getPrayers(req.session.userId), []) }); } catch (err) {
|
||||
console.error('GET /api/prayers error:', err);
|
||||
res.status(500).json({ error: 'Failed to load your prayers.' });
|
||||
}
|
||||
});
|
||||
app.put('/api/prayers', requireAuth, (req, res) => {
|
||||
try {
|
||||
const merged = mergePrayers(getPrayers(req.session.userId), req.body?.prayers);
|
||||
setPrayers(req.session.userId, merged);
|
||||
res.json({ prayers: merged });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/prayers error:', err);
|
||||
res.status(500).json({ error: 'Failed to save your prayers.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/reader/history — what has been read, and PUT merges this device's copy in and returns the whole
|
||||
app.get('/api/reader/history', requireAuth, (req, res) => {
|
||||
try { res.json(mergeHistory(getReaderHistory(req.session.userId), {})); } catch (err) {
|
||||
console.error('GET /api/reader/history error:', err);
|
||||
res.status(500).json({ error: 'Failed to load your reading history.' });
|
||||
}
|
||||
});
|
||||
app.put('/api/reader/history', requireAuth, (req, res) => {
|
||||
try {
|
||||
const merged = mergeHistory(getReaderHistory(req.session.userId), req.body);
|
||||
setReaderHistory(req.session.userId, merged);
|
||||
res.json(merged);
|
||||
} catch (err) {
|
||||
console.error('PUT /api/reader/history error:', err);
|
||||
res.status(500).json({ error: 'Failed to save your reading history.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/reader/marks — replace the user's bookmarks + highlights ({ bookmarks, highlights })
|
||||
app.put('/api/reader/marks', requireAuth, (req, res) => {
|
||||
try {
|
||||
const marks = cleanMarks(req.body);
|
||||
if (!marks) return res.status(400).json({ error: 'Body must be an object.' });
|
||||
res.json({ ok: true, updatedAt: setReaderMarks(req.session.userId, marks) });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/reader/marks error:', err);
|
||||
res.status(500).json({ error: 'Failed to save bookmarks and highlights.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reading plan progress — cross-device sync for started/completed reading plans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/reading-plans — the current user's whole progress list
|
||||
|
||||
app.get('/api/reading-plans', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json(getReadingProgress(req.session.userId));
|
||||
} catch (err) {
|
||||
console.error('GET /api/reading-plans error:', err);
|
||||
res.status(500).json({ error: 'Failed to load reading plan progress.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/reading-plans — overwrite the current user's whole progress list
|
||||
// (an object: { progress: [...], customPlans: [...] })
|
||||
app.put('/api/reading-plans', requireAuth, (req, res) => {
|
||||
try {
|
||||
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
|
||||
return res.status(400).json({ error: 'Body must be an object.' });
|
||||
}
|
||||
const { progress, customPlans, activity } = req.body;
|
||||
if ([progress, customPlans, activity].some((v) => v !== undefined && !Array.isArray(v))
|
||||
|| (progress?.length ?? 0) > 500 || (customPlans?.length ?? 0) > 200 || (activity?.length ?? 0) > 20000) {
|
||||
return res.status(400).json({ error: 'progress, customPlans and activity must be reasonably sized lists.' });
|
||||
}
|
||||
setReadingProgress(req.session.userId, { progress: progress ?? [], customPlans: customPlans ?? [], activity: activity ?? [] });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/reading-plans error:', err);
|
||||
res.status(500).json({ error: 'Failed to save reading plan progress.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Sharing: read-only study and outline links, co-teacher invites, and the public pages that open them.
|
||||
import { randomUUID } from 'crypto';
|
||||
import { acceptEditInvite, clearOutlineShareToken, clearShareToken, countOpenEditInvites, createEditInvite, getSharePassHash, getShareSettings, getSharedByToken, isProjectOwner, listCollaborators, removeCollaborator, setOutlineShareToken, setShareSettings, setShareToken } from '../db.js';
|
||||
import { requireAuth } from '../auth.js';
|
||||
import { checkShareAccess, expiryFromDays, hashPasscode, hideUntilReveal, revealFromInput } from '../shareLinks.js';
|
||||
import { buildOutlinePayload } from '../outlineShare.js';
|
||||
import { TOO_MANY, isLimited, recordFailure } from '../limiter.js';
|
||||
|
||||
export function registerSharingRoutes(app) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only share links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/projects/:id/share — current share status for the project owner
|
||||
app.get('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
try {
|
||||
const settings = getShareSettings(req.params.id, req.session.userId);
|
||||
if (!settings) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: settings.token, expiresAt: settings.expiresAt, hasPasscode: settings.hasPasscode, revealAt: settings.revealAt });
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to load share status.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Sets up (or changes) a read-only link: body { expiresInDays?: number | null, passcode?: string | null }.
|
||||
// Leaving a field out keeps what it was; null/"" clears it. Returns the (new or existing) token.
|
||||
async function saveLink(req, res, outline) {
|
||||
try {
|
||||
const userId = req.session.userId;
|
||||
const settings = getShareSettings(req.params.id, userId, outline);
|
||||
if (!settings) return res.status(404).json({ error: 'Project not found.' });
|
||||
const token = settings.token || randomUUID().replace(/-/g, '');
|
||||
if (!settings.token && !(outline ? setOutlineShareToken : setShareToken)(req.params.id, userId, token)) return res.status(404).json({ error: 'Project not found.' });
|
||||
const body = req.body ?? {};
|
||||
const expiresAt = 'expiresInDays' in body ? expiryFromDays(body.expiresInDays) : settings.expiresAt;
|
||||
// a blank passcode field means "keep it"; null clears; a string sets
|
||||
const current = getSharePassHash(req.params.id, userId, outline);
|
||||
const passHash = 'passcode' in body ? (body.passcode === null ? null : await hashPasscode(body.passcode) ?? current) : current;
|
||||
// only a study link can hide its notes until a time; a past or empty time turns hiding off
|
||||
const revealAt = outline ? null : ('revealAt' in body ? revealFromInput(body.revealAt) : settings.revealAt);
|
||||
setShareSettings(req.params.id, userId, { expiresAt, passHash, revealAt }, outline);
|
||||
res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash, revealAt });
|
||||
} catch (err) {
|
||||
console.error(`POST share (${outline ? 'outline' : 'study'}) error:`, err);
|
||||
res.status(500).json({ error: 'Failed to enable sharing.' });
|
||||
}
|
||||
}
|
||||
app.post('/api/projects/:id/share', requireAuth, (req, res) => saveLink(req, res, false));
|
||||
|
||||
// DELETE /api/projects/:id/share — revoke the share link
|
||||
app.delete('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
try {
|
||||
clearShareToken(req.params.id, req.session.userId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to revoke sharing.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Co-teachers: other accounts that may edit a study. The owner makes a single-use invite link; whoever
|
||||
// opens it while signed in joins. The owner (or the co-teacher themself) can remove an editor.
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/projects/:id/collaborators', requireAuth, (req, res) => {
|
||||
if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ collaborators: listCollaborators(req.params.id), pendingInvites: countOpenEditInvites(req.params.id) });
|
||||
});
|
||||
|
||||
app.post('/api/projects/:id/collaborators/invite', requireAuth, (req, res) => {
|
||||
if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
|
||||
const token = randomUUID().replace(/-/g, '');
|
||||
const expiresAt = Date.now() + 7 * 86_400_000;
|
||||
createEditInvite(token, req.params.id, expiresAt);
|
||||
res.json({ token, expiresAt });
|
||||
});
|
||||
|
||||
app.delete('/api/projects/:id/collaborators/:userId', requireAuth, (req, res) => {
|
||||
const me = req.session.userId;
|
||||
if (!isProjectOwner(req.params.id, me) && req.params.userId !== me) return res.status(403).json({ error: 'Only the owner can remove someone else.' });
|
||||
removeCollaborator(req.params.id, req.params.userId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/join/:token', requireAuth, (req, res) => {
|
||||
const result = acceptEditInvite(req.params.token, req.session.userId);
|
||||
if (result.error) return res.status(400).json({ error: result.error });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// Read-only outline links. A separate token from the study share link, and the public endpoint returns
|
||||
// only the outlines (see outlineShare.js), never the notes or scripts.
|
||||
app.get('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||
try {
|
||||
const settings = getShareSettings(req.params.id, req.session.userId, true);
|
||||
res.json({ shareToken: settings?.token ?? null, expiresAt: settings?.expiresAt ?? null, hasPasscode: !!settings?.hasPasscode });
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects/:id/outline-share error:', err);
|
||||
res.status(500).json({ error: 'Failed to load share status.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/projects/:id/outline-share', requireAuth, (req, res) => saveLink(req, res, true));
|
||||
|
||||
app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||
try {
|
||||
clearOutlineShareToken(req.params.id, req.session.userId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/projects/:id/outline-share error:', err);
|
||||
res.status(500).json({ error: 'Failed to revoke sharing.' });
|
||||
}
|
||||
});
|
||||
|
||||
// A wrong passcode counts as a failed attempt for that link and address
|
||||
async function openSharedLink(req, res, outline) {
|
||||
const found = getSharedByToken(req.params.token, outline);
|
||||
if (!found) { res.status(404).json({ error: outline ? 'This outline link is invalid or has been turned off.' : 'This share link is invalid or has been revoked.' }); return null; }
|
||||
const keys = [`ip:${req.ip}`, `share:${req.params.token}`];
|
||||
if (isLimited(...keys)) { res.status(429).json(TOO_MANY); return null; }
|
||||
const access = await checkShareAccess({ expiresAt: found.expiresAt, passHash: found.passHash, supplied: req.get('X-Share-Passcode') });
|
||||
if (!access.ok) {
|
||||
if (access.needsPasscode && req.get('X-Share-Passcode')) recordFailure(...keys);
|
||||
res.status(access.status).json({ error: access.error, needsPasscode: !!access.needsPasscode, expired: access.status === 410 });
|
||||
return null;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// GET /api/share-outline/:token — PUBLIC, no login required (a passcode may be needed)
|
||||
app.get('/api/share-outline/:token', async (req, res) => {
|
||||
try {
|
||||
const found = await openSharedLink(req, res, true);
|
||||
if (found) res.json(buildOutlinePayload(found.project));
|
||||
} catch (err) {
|
||||
console.error('GET /api/share-outline/:token error:', err);
|
||||
res.status(500).json({ error: 'Failed to load the shared outline.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only (a passcode may be needed)
|
||||
app.get('/api/share/:token', async (req, res) => {
|
||||
try {
|
||||
const found = await openSharedLink(req, res, false);
|
||||
if (found) res.json(hideUntilReveal(found.project, found.revealAt));
|
||||
} catch (err) {
|
||||
console.error('GET /api/share/:token error:', err);
|
||||
res.status(500).json({ error: 'Failed to load shared project.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import session from 'express-session';
|
||||
import { getSession, setSession, destroySession, pruneExpiredSessions } from './db.js';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* express-session store backed by the same SQLite database as everything else,
|
||||
* so logins survive a server restart without adding another dependency.
|
||||
*/
|
||||
export class SqliteSessionStore extends session.Store {
|
||||
constructor() {
|
||||
super();
|
||||
// Sweep expired sessions periodically instead of on every request.
|
||||
this._interval = setInterval(() => pruneExpiredSessions(), DAY_MS);
|
||||
this._interval.unref?.();
|
||||
}
|
||||
|
||||
get(sid, cb) {
|
||||
try {
|
||||
cb(null, getSession(sid));
|
||||
} catch (err) {
|
||||
cb(err);
|
||||
}
|
||||
}
|
||||
|
||||
set(sid, sessionData, cb) {
|
||||
try {
|
||||
const maxAge = sessionData.cookie?.maxAge ?? DAY_MS * 30;
|
||||
setSession(sid, sessionData, Date.now() + maxAge);
|
||||
cb?.(null);
|
||||
} catch (err) {
|
||||
cb?.(err);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(sid, cb) {
|
||||
try {
|
||||
destroySession(sid);
|
||||
cb?.(null);
|
||||
} catch (err) {
|
||||
cb?.(err);
|
||||
}
|
||||
}
|
||||
|
||||
touch(sid, sessionData, cb) {
|
||||
this.set(sid, sessionData, cb);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// The rules for a read-only link: it can expire, and it can need a passcode. Kept separate from the routes
|
||||
// so they can be tested on their own.
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export const MAX_PASSCODE = 100;
|
||||
|
||||
// "never" (null/0) or a number of days, up to a year -> an expiry time in ms (or null)
|
||||
export function expiryFromDays(days, now = Date.now()) {
|
||||
const n = Number(days);
|
||||
if (!days || !Number.isFinite(n) || n <= 0) return null;
|
||||
return now + Math.min(365, Math.round(n)) * 86_400_000;
|
||||
}
|
||||
|
||||
export async function hashPasscode(passcode) {
|
||||
const p = String(passcode ?? '').slice(0, MAX_PASSCODE);
|
||||
return p.trim() ? bcrypt.hash(p.trim(), 10) : null;
|
||||
}
|
||||
|
||||
// -> { ok: true } | { ok: false, status: 410, error } (expired) | { ok: false, status: 401, needsPasscode: true, error }
|
||||
export async function checkShareAccess({ expiresAt, passHash, supplied }, now = Date.now()) {
|
||||
if (expiresAt && expiresAt <= now) return { ok: false, status: 410, error: 'This link has expired. Ask the person who shared it for a new one.' };
|
||||
if (!passHash) return { ok: true };
|
||||
const given = String(supplied ?? '').slice(0, MAX_PASSCODE).trim();
|
||||
if (!given) return { ok: false, status: 401, needsPasscode: true, error: 'This link needs a passcode.' };
|
||||
if (!(await bcrypt.compare(given, passHash))) return { ok: false, status: 401, needsPasscode: true, error: 'That passcode is not right.' };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// "Hide my notes until…": before the reveal time a shared study shows only the passages and the discussion questions
|
||||
// (no notes, outline, script, words or group notes), and says when the rest appears. The strip is done here, on the
|
||||
// server, so the hidden text never reaches the browser. After the time (or with none set) the study is sent whole.
|
||||
const OPEN_CHUNK_FIELDS = ['id', 'startVerse', 'endVerse', 'spilloverEndVerse', 'episodeNumber', 'episodeTitle', 'discussionQuestions'];
|
||||
|
||||
export function hideUntilReveal(project, revealAt, now = Date.now()) {
|
||||
if (!revealAt || revealAt <= now || !project || typeof project !== 'object') return project;
|
||||
return {
|
||||
id: project.id, title: project.title, translation: project.translation, series: project.series, outline: [],
|
||||
hiddenUntil: revealAt,
|
||||
chapters: (Array.isArray(project.chapters) ? project.chapters : []).map((ch) => ({
|
||||
book: ch.book, bookAbbrev: ch.bookAbbrev, chapter: ch.chapter, verses: ch.verses,
|
||||
chunks: (Array.isArray(ch.chunks) ? ch.chunks : []).map((chunk) => Object.fromEntries(OPEN_CHUNK_FIELDS.filter((f) => f in chunk).map((f) => [f, chunk[f]]))),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// A reveal time from the browser -> ms in the future (up to a year), or null for "no hiding"
|
||||
export function revealFromInput(value, now = Date.now()) {
|
||||
const n = Number(value);
|
||||
if (!value || !Number.isFinite(n) || n <= now) return null;
|
||||
return Math.min(n, now + 365 * 86_400_000);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect } from 'vitest';
|
||||
import { checkShareAccess, expiryFromDays, hashPasscode } from './shareLinks.js';
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
|
||||
test('expiry is a number of days from now, or never', () => {
|
||||
expect(expiryFromDays(null, NOW)).toBeNull();
|
||||
expect(expiryFromDays(0, NOW)).toBeNull();
|
||||
expect(expiryFromDays('x', NOW)).toBeNull();
|
||||
expect(expiryFromDays(7, NOW)).toBe(NOW + 7 * 86_400_000);
|
||||
expect(expiryFromDays(9999, NOW)).toBe(NOW + 365 * 86_400_000); // never more than a year
|
||||
});
|
||||
|
||||
test('a link with no rules is open; an expired one says so', async () => {
|
||||
expect((await checkShareAccess({}, NOW)).ok).toBe(true);
|
||||
expect(await checkShareAccess({ expiresAt: NOW + 1000 }, NOW)).toEqual({ ok: true });
|
||||
expect(await checkShareAccess({ expiresAt: NOW - 1 }, NOW)).toMatchObject({ ok: false, status: 410 });
|
||||
});
|
||||
|
||||
test('a passcode is asked for, and only the right one opens the link', async () => {
|
||||
const passHash = await hashPasscode(' moses ');
|
||||
expect(passHash).not.toContain('moses');
|
||||
expect(await checkShareAccess({ passHash }, NOW)).toMatchObject({ ok: false, status: 401, needsPasscode: true });
|
||||
expect(await checkShareAccess({ passHash, supplied: 'aaron' }, NOW)).toMatchObject({ ok: false, status: 401, needsPasscode: true, error: expect.stringMatching(/not right/) });
|
||||
expect(await checkShareAccess({ passHash, supplied: ' moses ' }, NOW)).toEqual({ ok: true });
|
||||
expect(await hashPasscode(' ')).toBeNull();
|
||||
});
|
||||
|
||||
test('an expired link is reported as expired even before the passcode is checked', async () => {
|
||||
const passHash = await hashPasscode('x');
|
||||
expect(await checkShareAccess({ passHash, expiresAt: NOW - 5, supplied: 'x' }, NOW)).toMatchObject({ status: 410 });
|
||||
});
|
||||
|
||||
import { hideUntilReveal, revealFromInput } from './shareLinks.js';
|
||||
|
||||
const study = () => ({
|
||||
id: 's1', title: 'John', translation: 'BSB', outline: [{ id: 'n', text: 'Point' }],
|
||||
chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [{ number: 16, text: 'For God so loved.' }],
|
||||
chunks: [{ id: 'c', startVerse: 16, endVerse: 18, discussionQuestions: 'Why?', observation: 'secret', interpretation: 'secret', application: 'secret', finalScript: 'secret', generalNotes: 'secret', greekWords: [{ id: 'w' }], teachBack: 'secret', recalls: [{ text: 'secret' }] }] }],
|
||||
});
|
||||
|
||||
test('before the reveal time a shared study keeps the passages and questions and drops every note', () => {
|
||||
const now = 1_000;
|
||||
const out = hideUntilReveal(study(), now + 5_000, now);
|
||||
expect(out.hiddenUntil).toBe(now + 5_000);
|
||||
expect(out.outline).toEqual([]);
|
||||
expect(out.chapters[0].verses).toHaveLength(1);
|
||||
expect(out.chapters[0].chunks[0]).toEqual({ id: 'c', startVerse: 16, endVerse: 18, discussionQuestions: 'Why?' });
|
||||
expect(JSON.stringify(out)).not.toContain('secret');
|
||||
});
|
||||
|
||||
test('after the reveal time, or with none set, the whole study is sent', () => {
|
||||
const full = study();
|
||||
expect(hideUntilReveal(full, 500, 1_000)).toBe(full);
|
||||
expect(hideUntilReveal(full, null, 1_000)).toBe(full);
|
||||
});
|
||||
|
||||
test('a reveal time must be in the future, and no more than a year away', () => {
|
||||
const now = 1_000_000;
|
||||
expect(revealFromInput(null, now)).toBeNull();
|
||||
expect(revealFromInput(now - 1, now)).toBeNull();
|
||||
expect(revealFromInput('nope', now)).toBeNull();
|
||||
expect(revealFromInput(now + 60_000, now)).toBe(now + 60_000);
|
||||
expect(revealFromInput(now + 10 * 365 * 86_400_000, now)).toBe(now + 365 * 86_400_000);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// Who may create an account. The administrator picks one of three settings:
|
||||
// open anyone who can reach the server (the original behaviour)
|
||||
// invite only someone with an unused invite code from the administrator
|
||||
// closed nobody new
|
||||
// The very first account can always be made, so a fresh install can't lock itself out.
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export const SIGNUP_MODES = ['open', 'invite', 'closed'];
|
||||
export const isSignupMode = (m) => SIGNUP_MODES.includes(m);
|
||||
|
||||
// -> { ok: true } or { ok: false, status, error }. `inviteOk` says whether the code given is a valid unused one.
|
||||
export function checkSignup({ mode, userCount, inviteOk }) {
|
||||
if (userCount === 0 || mode === 'open') return { ok: true };
|
||||
if (mode === 'closed') return { ok: false, status: 403, error: 'Sign-up is closed. Ask the administrator for an account.' };
|
||||
if (!inviteOk) return { ok: false, status: 403, error: 'Sign-up needs an invite code from the administrator.' };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// An easy-to-read invite code (no look-alike characters), e.g. K7QP-4MXD-9TRW.
|
||||
const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||
export function generateInviteCode() {
|
||||
const chars = Array.from(randomBytes(12), (b) => ALPHABET[b % ALPHABET.length]).join('');
|
||||
return `${chars.slice(0, 4)}-${chars.slice(4, 8)}-${chars.slice(8, 12)}`;
|
||||
}
|
||||
export const normalizeInviteCode = (code) => String(code ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '').replace(/^(.{4})(.{4})(.{4})$/, '$1-$2-$3');
|
||||
@@ -0,0 +1,24 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect } from 'vitest';
|
||||
import { checkSignup, generateInviteCode, normalizeInviteCode, isSignupMode } from './signup.js';
|
||||
|
||||
test('open lets anyone in; the first account is always allowed', () => {
|
||||
expect(checkSignup({ mode: 'open', userCount: 5, inviteOk: false }).ok).toBe(true);
|
||||
expect(checkSignup({ mode: 'closed', userCount: 0, inviteOk: false }).ok).toBe(true);
|
||||
expect(checkSignup({ mode: 'invite', userCount: 0, inviteOk: false }).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('invite-only needs a good code, closed lets nobody in', () => {
|
||||
expect(checkSignup({ mode: 'invite', userCount: 2, inviteOk: false })).toMatchObject({ ok: false, status: 403 });
|
||||
expect(checkSignup({ mode: 'invite', userCount: 2, inviteOk: true }).ok).toBe(true);
|
||||
expect(checkSignup({ mode: 'closed', userCount: 2, inviteOk: true })).toMatchObject({ ok: false, status: 403 });
|
||||
});
|
||||
|
||||
test('invite codes are readable and typed forgivingly', () => {
|
||||
const code = generateInviteCode();
|
||||
expect(code).toMatch(/^[A-HJKMNP-Z2-9]{4}-[A-HJKMNP-Z2-9]{4}-[A-HJKMNP-Z2-9]{4}$/);
|
||||
expect(normalizeInviteCode(` ${code.toLowerCase().replace(/-/g, ' ')} `)).toBe(code);
|
||||
expect(normalizeInviteCode('abcd2345jkmn')).toBe('ABCD-2345-JKMN');
|
||||
expect(isSignupMode('invite')).toBe(true);
|
||||
expect(isSignupMode('nope')).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { isAdminEmail } from './auth.js';
|
||||
import { votdAvailable } from './votd.js';
|
||||
import { listPasskeys } from './db.js';
|
||||
|
||||
// Shape returned to the client for the signed-in user (login, MFA verify, /auth/me).
|
||||
export function userPayload(user) {
|
||||
return {
|
||||
id: user.id, email: user.email, totpEnabled: user.totpEnabled, podcastName: user.podcastName ?? null,
|
||||
isAdmin: isAdminEmail(user.email),
|
||||
reminderEnabled: user.reminderEnabled, ntfyUrl: user.ntfyUrl ?? null, ntfyTopic: user.ntfyTopic ?? null,
|
||||
reminderTime: user.reminderTime ?? null,
|
||||
votdEnabled: !!user.votdEnabled, votdTime: user.votdTime ?? null, votdAvailable: votdAvailable(),
|
||||
prayerEnabled: !!user.prayerEnabled, prayerTime: user.prayerTime ?? null,
|
||||
loginAlerts: !!user.loginAlerts, passkeyCount: listPasskeys(user.id).length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Earlier versions of a study. Just before a save replaces what the server holds, that copy can be kept, so a
|
||||
// bad merge or an accidental delete can be undone. To stay small, a copy is kept at most once per window of
|
||||
// editing, it is compressed, and only the newest few are kept.
|
||||
import { gunzipSync, gzipSync } from 'zlib';
|
||||
|
||||
export const MIN_GAP_MS = 10 * 60 * 1000; // one version per ten minutes of editing history
|
||||
export const MAX_VERSIONS = 30;
|
||||
|
||||
// `prevEditedAt` is when the copy about to be replaced was saved; `lastKeptAt` is when the newest kept version was.
|
||||
export function shouldSnapshot({ prevEditedAt, lastKeptAt, force = false, minGap = MIN_GAP_MS }) {
|
||||
if (!Number.isFinite(prevEditedAt)) return false;
|
||||
if (force) return true;
|
||||
return !Number.isFinite(lastKeptAt) || prevEditedAt - lastKeptAt >= minGap;
|
||||
}
|
||||
|
||||
export const pack = (json) => gzipSync(Buffer.from(typeof json === 'string' ? json : JSON.stringify(json)));
|
||||
export const unpack = (buf) => JSON.parse(gunzipSync(buf).toString('utf8'));
|
||||
|
||||
const words = (s) => (String(s ?? '').trim() ? String(s).trim().split(/\s+/).length : 0);
|
||||
|
||||
// How big a study is: chunks and the words in its notes and scripts.
|
||||
export function summarizeProject(project) {
|
||||
let chunks = 0;
|
||||
let noteWords = 0;
|
||||
for (const chapter of project?.chapters ?? []) {
|
||||
for (const chunk of chapter.chunks ?? []) {
|
||||
chunks += 1;
|
||||
for (const f of ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript']) noteWords += words(chunk[f]);
|
||||
}
|
||||
}
|
||||
return { chunks, words: noteWords };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect } from 'vitest';
|
||||
import { MIN_GAP_MS, pack, shouldSnapshot, summarizeProject, unpack } from './versions.js';
|
||||
|
||||
test('a version is kept once per window of editing, and always the first', () => {
|
||||
const t = 1_800_000_000_000;
|
||||
expect(shouldSnapshot({ prevEditedAt: t, lastKeptAt: undefined })).toBe(true);
|
||||
expect(shouldSnapshot({ prevEditedAt: t, lastKeptAt: t - 60_000 })).toBe(false); // a minute after the last one
|
||||
expect(shouldSnapshot({ prevEditedAt: t, lastKeptAt: t - MIN_GAP_MS })).toBe(true);
|
||||
expect(shouldSnapshot({ prevEditedAt: t, lastKeptAt: t - 1000, force: true })).toBe(true); // before a restore
|
||||
expect(shouldSnapshot({ prevEditedAt: undefined, lastKeptAt: undefined })).toBe(false); // nothing to keep
|
||||
});
|
||||
|
||||
test('a stored copy round-trips and is smaller than the original', () => {
|
||||
const study = { id: 'a', title: 'T', chapters: [{ verses: Array.from({ length: 200 }, (_, i) => ({ number: i, text: 'In the beginning God created the heavens and the earth.' })) }] };
|
||||
const packed = pack(study);
|
||||
expect(packed.length).toBeLessThan(JSON.stringify(study).length / 5);
|
||||
expect(unpack(packed)).toEqual(study);
|
||||
});
|
||||
|
||||
test('a study is summarised by its chunks and the words in its notes', () => {
|
||||
const s = summarizeProject({ chapters: [{ chunks: [{ observation: 'one two three', application: ' four ' }, { finalScript: 'a b' }] }, { chunks: [{}] }] });
|
||||
expect(s).toEqual({ chunks: 3, words: 6 });
|
||||
expect(summarizeProject(null)).toEqual({ chunks: 0, words: 0 });
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// Today's verse of the day for the push notification: IQ Bible picks it (the same verse for everyone each UTC day),
|
||||
// in the KJV, which is free to quote. Needs IQBIBLE_API_KEY on the server. One lookup a day serves every user.
|
||||
import { bookNameFor } from './bibleApi.js';
|
||||
|
||||
let cached = null; // { date, verse }
|
||||
|
||||
export const votdAvailable = () => Boolean(process.env.IQBIBLE_API_KEY);
|
||||
|
||||
// IQ Bible's answer -> { reference, text }, or null when it isn't one we can use
|
||||
export function parseVotd(body) {
|
||||
const d = body?.data;
|
||||
const book = bookNameFor(d?.book_usfm);
|
||||
if (!book || !d?.chapter_number || !d?.verse_number || typeof d.text !== 'string' || !d.text.trim()) return null;
|
||||
return { reference: `${book} ${d.chapter_number}:${d.verse_number}`, text: d.text.replace(/^[¶§*\s]+/, '').replace(/\s+/g, ' ').trim() };
|
||||
}
|
||||
|
||||
export async function fetchVerseOfDay(now = new Date(), fetcher = fetch) {
|
||||
const date = now.toISOString().slice(0, 10);
|
||||
if (cached?.date === date) return cached.verse;
|
||||
if (!votdAvailable()) return null;
|
||||
const base = (process.env.IQBIBLE_API_BASE || 'https://api.iqbible.com/api/v2').replace(/\/+$/, '');
|
||||
try {
|
||||
const res = await fetcher(`${base}/bibles/eng_kjv/verse-of-the-day`, { headers: { 'X-IQBible-API-Key': process.env.IQBIBLE_API_KEY }, signal: AbortSignal.timeout(10_000) });
|
||||
if (!res.ok) return null;
|
||||
const verse = parseVotd(await res.json());
|
||||
if (verse) cached = { date, verse };
|
||||
return verse;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export const clearVotdCache = () => { cached = null; };
|
||||
|
||||
export const votdMessage = ({ reference, text }) => ({ title: `Verse of the day: ${reference}`, body: `${text}\n\n${reference} (KJV)`, tags: 'sunrise' });
|
||||
@@ -0,0 +1,40 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { parseVotd, fetchVerseOfDay, clearVotdCache, votdMessage } from './votd.js';
|
||||
|
||||
beforeEach(() => { clearVotdCache(); process.env.IQBIBLE_API_KEY = 'k'; });
|
||||
afterEach(() => { delete process.env.IQBIBLE_API_KEY; vi.restoreAllMocks(); });
|
||||
|
||||
const body = { data: { book_usfm: 'ROM', chapter_number: 8, verse_number: 28, text: '¶ And we know that all things work together for good.' } };
|
||||
const ok = () => vi.fn(async () => ({ ok: true, json: async () => body }));
|
||||
|
||||
test('IQ Bible\'s verse becomes a reference and clean text', () => {
|
||||
expect(parseVotd(body)).toEqual({ reference: 'Romans 8:28', text: 'And we know that all things work together for good.' });
|
||||
expect(parseVotd({ data: { book_usfm: 'XXX', chapter_number: 1, verse_number: 1, text: 'x' } })).toBeNull();
|
||||
expect(parseVotd({})).toBeNull();
|
||||
});
|
||||
|
||||
test('the notification carries the verse and its reference', () => {
|
||||
expect(votdMessage({ reference: 'Romans 8:28', text: 'All things.' })).toEqual({ title: 'Verse of the day: Romans 8:28', body: 'All things.\n\nRomans 8:28 (KJV)', tags: 'sunrise' });
|
||||
});
|
||||
|
||||
test('one lookup a day serves everyone, and a new day looks again', async () => {
|
||||
const fetcher = ok();
|
||||
const day1 = new Date('2026-10-06T12:00:00Z');
|
||||
expect((await fetchVerseOfDay(day1, fetcher)).reference).toBe('Romans 8:28');
|
||||
await fetchVerseOfDay(new Date('2026-10-06T23:00:00Z'), fetcher);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
await fetchVerseOfDay(new Date('2026-10-07T01:00:00Z'), fetcher);
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(fetcher.mock.calls[0][0]).toContain('/bibles/eng_kjv/verse-of-the-day');
|
||||
expect(fetcher.mock.calls[0][1].headers['X-IQBible-API-Key']).toBe('k');
|
||||
});
|
||||
|
||||
test('without a key, or when IQ Bible fails, there is no verse (and nothing is remembered)', async () => {
|
||||
delete process.env.IQBIBLE_API_KEY;
|
||||
expect(await fetchVerseOfDay(new Date(), ok())).toBeNull();
|
||||
process.env.IQBIBLE_API_KEY = 'k';
|
||||
expect(await fetchVerseOfDay(new Date(), vi.fn(async () => ({ ok: false })))).toBeNull();
|
||||
expect(await fetchVerseOfDay(new Date(), vi.fn(async () => { throw new Error('down'); }))).toBeNull();
|
||||
expect((await fetchVerseOfDay(new Date(), ok())).reference).toBe('Romans 8:28'); // a later good answer still works
|
||||
});
|
||||
@@ -1,6 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installs dependencies, builds the frontend, and runs the app.
|
||||
#
|
||||
# bash setup.sh Production: one Express server serves the optimized
|
||||
# build + API on $APP_PORT (default 5173).
|
||||
# bash setup.sh --dev Development: Express API on 3001 + Vite dev server
|
||||
# with hot reload (slower; not for real users).
|
||||
set -euo pipefail
|
||||
|
||||
MODE="prod"
|
||||
if [ "${1:-}" = "--dev" ]; then
|
||||
MODE="dev"
|
||||
fi
|
||||
|
||||
# Production serves on the port the Vite dev server used to use, so an
|
||||
# existing reverse proxy pointed at it keeps working. Override with APP_PORT=.
|
||||
APP_PORT="${APP_PORT:-5173}"
|
||||
PREFERRED_VITE_PORT=5173
|
||||
MAX_VITE_PORT=5200
|
||||
API_PORT=3001
|
||||
@@ -41,7 +55,7 @@ stop_pid_file() {
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Bible Study App Setup ==="
|
||||
echo "=== Bible Study App Setup ($MODE) ==="
|
||||
echo ""
|
||||
|
||||
# ── Node check ───────────────────────────────────────────────────────────────
|
||||
@@ -68,7 +82,35 @@ echo ""
|
||||
echo "Installing dependencies..."
|
||||
npm install
|
||||
|
||||
# ── Find a free Vite port ─────────────────────────────────────────────────────
|
||||
# ── Production ────────────────────────────────────────────────────────────────
|
||||
if [ "$MODE" = "prod" ]; then
|
||||
# Build before stopping anything, so a failed build leaves the running site up.
|
||||
echo ""
|
||||
echo "Building frontend..."
|
||||
npm run build
|
||||
|
||||
stop_pid_file "$API_PID_FILE" "server"
|
||||
stop_pid_file "$VITE_PID_FILE" "Vite dev server"
|
||||
|
||||
echo ""
|
||||
echo "Starting production server on http://localhost:$APP_PORT"
|
||||
echo "Logs: $API_LOG_FILE"
|
||||
|
||||
NODE_ENV=production PORT="$APP_PORT" \
|
||||
nohup node "$ROOT_DIR/server/index.js" > "$API_LOG_FILE" 2>&1 &
|
||||
echo $! > "$API_PID_FILE"
|
||||
echo "Server PID: $(cat "$API_PID_FILE")"
|
||||
|
||||
echo ""
|
||||
echo "To stop: kill \$(cat .api.pid)"
|
||||
echo "To view logs: tail -f .api.log"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Development ───────────────────────────────────────────────────────────────
|
||||
stop_pid_file "$API_PID_FILE" "API server"
|
||||
stop_pid_file "$VITE_PID_FILE" "Vite server"
|
||||
|
||||
echo ""
|
||||
VITE_PORT=$(find_port)
|
||||
if [ -z "$VITE_PORT" ]; then
|
||||
@@ -82,11 +124,6 @@ else
|
||||
echo "Port $VITE_PORT is available for Vite."
|
||||
fi
|
||||
|
||||
# ── Stop any previously running instances ─────────────────────────────────────
|
||||
stop_pid_file "$API_PID_FILE" "API server"
|
||||
stop_pid_file "$VITE_PID_FILE" "Vite server"
|
||||
|
||||
# ── Start Express API server ──────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Starting API server on http://localhost:$API_PORT"
|
||||
echo "API logs: $API_LOG_FILE"
|
||||
@@ -98,7 +135,6 @@ echo "API server PID: $(cat "$API_PID_FILE")"
|
||||
# Give the API a moment to initialise before Vite starts
|
||||
sleep 1
|
||||
|
||||
# ── Start Vite dev server ─────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Starting Vite dev server on http://localhost:$VITE_PORT"
|
||||
echo "Vite logs: $VITE_LOG_FILE"
|
||||
@@ -112,4 +148,4 @@ echo "Both servers are running."
|
||||
echo ""
|
||||
echo "To stop both: kill \$(cat .api.pid) \$(cat .vite.pid)"
|
||||
echo "To view API logs: tail -f .api.log"
|
||||
echo "To view Vite logs: tail -f .vite.log"
|
||||
echo "To view Vite logs: tail -f .vite.log"
|
||||
@@ -0,0 +1,443 @@
|
||||
// How the app moves and recovers: the Back button, the ⌘K palette, Undo, sync conflicts, backups.
|
||||
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
|
||||
import App from './App';
|
||||
import { resetLexiconCaches } from './utils/wordStudy.js';
|
||||
import { switchStorageUser } from './utils/bibleUtils.jsx';
|
||||
import { resetSyncBases } from './utils/syncBase.js';
|
||||
|
||||
const chapterData = {
|
||||
chapter: { content: [
|
||||
{ type: 'verse', number: 1, content: ['In the beginning God created.'] },
|
||||
{ type: 'verse', number: 2, content: ['The earth was without form.'] },
|
||||
] },
|
||||
};
|
||||
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||
|
||||
// A fake server: signed in as `user`, a settable answer for saving studies, and a log of every call.
|
||||
function stubServer({ user = null, putStatus = 200 } = {}) {
|
||||
const calls = [];
|
||||
const state = { putStatus, outlineToken: null };
|
||||
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
|
||||
const route = `${opts.method ?? 'GET'} ${url.split('?')[0]}`;
|
||||
calls.push({ route, url, opts });
|
||||
if (url.includes('bible.helloao.org')) return json(200, chapterData);
|
||||
if (!url.startsWith('/api/')) return Promise.resolve({ ok: false });
|
||||
if (route === 'GET /api/auth/me') return user ? json(200, user) : json(401, { error: 'Not signed in' });
|
||||
if (route === 'GET /api/projects') return json(200, []);
|
||||
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||
if (route.endsWith('/outline-share')) {
|
||||
if (opts.method === 'POST') state.outlineToken = 'abc123';
|
||||
if (opts.method === 'DELETE') state.outlineToken = null;
|
||||
return json(200, opts.method === 'DELETE' ? { ok: true } : { shareToken: state.outlineToken ?? null });
|
||||
}
|
||||
if (route === 'GET /api/share-outline/abc123') {
|
||||
return json(200, { title: 'Titus', translation: 'BSB', outlines: [
|
||||
{ scope: 'study', label: 'Whole study', tree: [{ id: 'a', text: 'Appoint elders', ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [] }] },
|
||||
{ scope: 'c1', label: 'Titus 1:1–4', tree: [{ id: 'b', text: 'Greeting', ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [] }] },
|
||||
] });
|
||||
}
|
||||
if (route === 'GET /api/share-outline/nope') return json(404, { error: 'This outline link is invalid or has been turned off.' });
|
||||
if (route.startsWith('PUT /api/projects/')) {
|
||||
return state.putStatus === 409
|
||||
? json(409, { error: 'This study was changed on another device.', code: 'conflict', serverEdited: 999 })
|
||||
: json(200, { id: url.split('/').pop().split('?')[0], title: 'x', lastEdited: 12345, chapterSummary: '' });
|
||||
}
|
||||
return json(404, { error: 'not mocked' });
|
||||
}));
|
||||
return { calls, state };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetLexiconCaches();
|
||||
resetSyncBases();
|
||||
switchStorageUser(null);
|
||||
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false })));
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); });
|
||||
|
||||
async function renderApp() {
|
||||
render(<App />);
|
||||
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
|
||||
}
|
||||
|
||||
function seedProject({ id = 'p1', title = 'Titus Notes', observation = 'Grace abounds to all.' } = {}) {
|
||||
const project = {
|
||||
id, title, translation: 'BSB', lastEdited: Date.now(), selectedChunkId: null,
|
||||
chapters: [{
|
||||
book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'Paul, a servant of God.' }],
|
||||
chunks: [{ id: 'c1', startVerse: 1, endVerse: 1, observation, interpretation: '', application: '', crossReferences: [], greekWords: [], tags: [] }],
|
||||
}],
|
||||
};
|
||||
const index = JSON.parse(localStorage.getItem('bible-study-index') || '[]');
|
||||
index.push({ id, title, lastEdited: project.lastEdited, chapterSummary: 'Titus 1', tags: [] });
|
||||
localStorage.setItem(`bible-study-project-${id}`, JSON.stringify(project));
|
||||
localStorage.setItem('bible-study-index', JSON.stringify(index));
|
||||
}
|
||||
|
||||
describe('Back button', () => {
|
||||
test('moves back through the pages you visited instead of leaving the app', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||
expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument();
|
||||
window.history.back();
|
||||
expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument();
|
||||
window.history.forward();
|
||||
expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
const paletteBox = async () => within(await screen.findByRole('dialog', { name: /search and go to/i })).getByRole('combobox');
|
||||
|
||||
describe('Search palette (⌘K)', () => {
|
||||
test('a typed passage goes straight to the Bible reader', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.keyboard('{Control>}k{/Control}');
|
||||
await user.type(await paletteBox(), 'john 3:16');
|
||||
expect(await screen.findByRole('option', { name: /John 3:16/ })).toBeInTheDocument();
|
||||
await user.keyboard('{Enter}');
|
||||
expect(await screen.findByRole('heading', { name: /John 3/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('finds a note you wrote and opens that study', async () => {
|
||||
seedProject({ observation: 'Grace abounds to all.' });
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.keyboard('{Control>}k{/Control}');
|
||||
await user.type(await paletteBox(), 'abounds');
|
||||
const hit = await screen.findByRole('option', { name: /Titus 1:1/ });
|
||||
await user.click(hit);
|
||||
expect(await screen.findByText(/Your notes/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Escape closes it', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.keyboard('{Control>}k{/Control}');
|
||||
await paletteBox();
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('? lists the keyboard shortcuts', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.keyboard('?');
|
||||
expect(await screen.findByRole('dialog', { name: /keyboard shortcuts/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Undo', () => {
|
||||
test('deleting a project, then Undo, keeps its notes', async () => {
|
||||
seedProject();
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.click(screen.getByRole('button', { name: /^delete$/i }));
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/deleted/i);
|
||||
await user.click(screen.getByRole('button', { name: /undo/i }));
|
||||
expect(await screen.findByText('Titus Notes')).toBeInTheDocument();
|
||||
expect(JSON.parse(localStorage.getItem('bible-study-project-p1')).chapters[0].chunks[0].observation).toBe('Grace abounds to all.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sync conflict', () => {
|
||||
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false };
|
||||
|
||||
async function startStudy(u) {
|
||||
await renderApp();
|
||||
await u.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
|
||||
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||
await screen.findByText(/Scripture & Chunks/i);
|
||||
}
|
||||
|
||||
test('a save the server refuses shows the choice instead of overwriting', async () => {
|
||||
const server = stubServer({ user, putStatus: 409 });
|
||||
const u = userEvent.setup();
|
||||
await startStudy(u);
|
||||
const banner = await screen.findByRole('alert', {}, { timeout: 4000 });
|
||||
expect(banner).toHaveTextContent(/changed on another device/i);
|
||||
expect(within(banner).getByRole('button', { name: /keep this version/i })).toBeInTheDocument();
|
||||
expect(within(banner).getByRole('button', { name: /use the other version/i })).toBeInTheDocument();
|
||||
|
||||
// "Keep this version" sends the save again, this time as an overwrite
|
||||
server.state.putStatus = 200;
|
||||
await u.click(within(banner).getByRole('button', { name: /keep this version/i }));
|
||||
await waitFor(() => expect(server.calls.some((c) => c.url.includes('?force=1'))).toBe(true));
|
||||
await waitFor(() => expect(screen.queryByText(/changed on another device/i)).not.toBeInTheDocument());
|
||||
}, 15000);
|
||||
|
||||
test('opening a study without editing it does not touch the server', async () => {
|
||||
seedProject();
|
||||
const server = stubServer({ user });
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
// signed in: the namespaced storage starts empty, so seed under the user's keys by resuming nothing
|
||||
expect(server.calls.filter((c) => c.route.startsWith('PUT')).length).toBe(0);
|
||||
await u.click(await screen.findByRole('button', { name: /read bible/i }));
|
||||
expect(server.calls.filter((c) => c.route.startsWith('PUT /api/projects')).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Outline stage', () => {
|
||||
async function openOutline() {
|
||||
// local-only mode (the account server is unreachable), with chapter text available
|
||||
vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false }))));
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
|
||||
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||
await screen.findByText(/Scripture & Chunks/i);
|
||||
// one chunk covering verses 1-2
|
||||
const verses = within(screen.getByTestId('verse-list'));
|
||||
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
|
||||
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
|
||||
await screen.findByText('1 created');
|
||||
await user.click(screen.getByRole('button', { name: /^outline$/i }));
|
||||
await screen.findByRole('heading', { name: 'Outline' });
|
||||
return user;
|
||||
}
|
||||
|
||||
test('starts empty, and "Start from my chunks" makes a main point for each chunk', async () => {
|
||||
const user = await openOutline();
|
||||
expect(screen.getByText(/no outline yet/i)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
|
||||
const list = await screen.findByRole('list', { name: 'Outline' });
|
||||
expect(within(list).getAllByRole('textbox')).toHaveLength(1);
|
||||
expect(within(list).getByText(/Open chunk in Study/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Enter adds a point, Tab makes it a sub-point, and they are numbered I. and A.', async () => {
|
||||
const user = await openOutline();
|
||||
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||
const first = await screen.findByRole('textbox', { name: 'Point I.' });
|
||||
await user.type(first, 'Creation{Enter}');
|
||||
const second = await screen.findByRole('textbox', { name: 'Point II.' });
|
||||
await user.type(second, 'Light{Tab}');
|
||||
expect(await screen.findByRole('textbox', { name: 'Point A.' })).toHaveValue('Light');
|
||||
expect(screen.getByRole('textbox', { name: 'Point I.' })).toHaveValue('Creation');
|
||||
});
|
||||
|
||||
test('deleting a point can be undone', async () => {
|
||||
const user = await openOutline();
|
||||
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||
await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'Keep me');
|
||||
await user.click(screen.getByRole('button', { name: /delete i\./i }));
|
||||
expect(screen.queryByRole('textbox', { name: 'Point I.' })).not.toBeInTheDocument();
|
||||
await user.click(await screen.findByRole('button', { name: /undo/i }));
|
||||
expect(await screen.findByRole('textbox', { name: 'Point I.' })).toHaveValue('Keep me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Outline: per chunk, details, print and share', () => {
|
||||
async function openOutline() {
|
||||
vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false }))));
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
|
||||
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||
await screen.findByText(/Scripture & Chunks/i);
|
||||
const verses = within(screen.getByTestId('verse-list'));
|
||||
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
|
||||
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
|
||||
await screen.findByText('1 created');
|
||||
await user.click(screen.getByRole('button', { name: /^outline$/i }));
|
||||
await screen.findByRole('heading', { name: 'Outline' });
|
||||
return user;
|
||||
}
|
||||
|
||||
test('each chunk has its own outline, separate from the whole study\'s', async () => {
|
||||
const user = await openOutline();
|
||||
const picker = screen.getByRole('combobox', { name: 'Outline for' });
|
||||
await user.selectOptions(picker, picker.querySelectorAll('option')[1]); // the chunk
|
||||
expect(await screen.findByText(/no outline yet for/i)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /start from the verses/i }));
|
||||
const list = await screen.findByRole('list', { name: 'Outline' });
|
||||
expect(within(list).getAllByRole('textbox')).toHaveLength(2); // one point per verse
|
||||
expect(within(list).getByText(/In the beginning God created/)).toBeInTheDocument(); // with its words
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Outline for' }), 'study');
|
||||
expect(await screen.findByText(/^No outline yet$/)).toBeInTheDocument(); // the whole study is untouched
|
||||
expect(screen.getByRole('option', { name: /· 2/ })).toBeInTheDocument(); // the chunk shows its count
|
||||
});
|
||||
|
||||
test('a point can be given a type and minutes, and the total shows', async () => {
|
||||
const user = await openOutline();
|
||||
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||
await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'The story');
|
||||
await user.click(screen.getByRole('button', { name: /details for i\./i }));
|
||||
await user.selectOptions(screen.getByLabelText('Type'), 'illustration');
|
||||
await user.type(screen.getByLabelText('Minutes'), '5');
|
||||
expect(await screen.findByText('About 5 minutes')).toBeInTheDocument();
|
||||
await user.type(screen.getByRole('textbox', { name: 'Notes for I.' }), 'Tell it slowly');
|
||||
await user.click(screen.getByRole('button', { name: /details for i\./i })); // close: the note shows as a line
|
||||
expect(await screen.findByText('Tell it slowly')).toBeInTheDocument();
|
||||
expect(screen.getByText('Illustration')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('"Add verse text" puts the passage\'s words into the point', async () => {
|
||||
const user = await openOutline();
|
||||
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||
await user.click(await screen.findByRole('button', { name: /details for i\./i }));
|
||||
await user.type(screen.getByLabelText('Passage'), 'Titus 1:1');
|
||||
await user.click(screen.getByRole('button', { name: /add verse text/i }));
|
||||
expect(await screen.findByText(/In the beginning God created/)).toBeInTheDocument();
|
||||
await user.clear(screen.getByLabelText('Passage'));
|
||||
await user.type(screen.getByLabelText('Passage'), 'nonsense');
|
||||
await user.click(screen.getByRole('button', { name: /replace verse text/i }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/type a passage/i);
|
||||
});
|
||||
|
||||
test('Share opens a sheet; with no account it offers the text, which is copied', async () => {
|
||||
const user = await openOutline();
|
||||
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
|
||||
await screen.findByRole('list', { name: 'Outline' });
|
||||
await user.click(screen.getByRole('button', { name: /^share$/i }));
|
||||
const sheet = await screen.findByRole('dialog', { name: /share this outline/i });
|
||||
expect(await within(sheet).findByText(/links need your account/i)).toBeInTheDocument();
|
||||
await user.click(within(sheet).getByRole('button', { name: /share as text/i }));
|
||||
expect(await screen.findByText(/outline copied/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Print opens a page of just the outline', async () => {
|
||||
const user = await openOutline();
|
||||
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
|
||||
await screen.findByRole('list', { name: 'Outline' });
|
||||
const doc = { write: vi.fn(), close: vi.fn() };
|
||||
vi.spyOn(window, 'open').mockReturnValue({ document: doc, focus: vi.fn(), print: vi.fn() });
|
||||
await user.click(screen.getByRole('button', { name: /print \/ pdf/i }));
|
||||
expect(doc.write.mock.calls[0][0]).toContain('class="outline"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Read-only outline link', () => {
|
||||
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false };
|
||||
|
||||
test('create the link, see it, and turn it off', async () => {
|
||||
const server = stubServer({ user });
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await u.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
|
||||
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||
await screen.findByText(/Scripture & Chunks/i);
|
||||
const verses = within(screen.getByTestId('verse-list'));
|
||||
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
|
||||
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
|
||||
await screen.findByText('1 created');
|
||||
await u.click(screen.getByRole('button', { name: /^outline$/i }));
|
||||
await u.click(await screen.findByRole('button', { name: /start from my chunks/i }));
|
||||
await u.click(await screen.findByRole('button', { name: /^share$/i }));
|
||||
const sheet = await screen.findByRole('dialog', { name: /share this outline/i });
|
||||
await u.click(await within(sheet).findByRole('button', { name: /create read-only link/i }));
|
||||
const box = await within(sheet).findByLabelText('Outline link');
|
||||
expect(box.value).toMatch(/\?outline=abc123$/);
|
||||
expect(server.calls.some((c) => c.route === 'POST /api/projects/' + c.url.split('/')[3] + '/outline-share')).toBe(true);
|
||||
await u.click(within(sheet).getByRole('button', { name: /turn off link/i }));
|
||||
expect(await within(sheet).findByRole('button', { name: /create read-only link/i })).toBeInTheDocument();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe('Opening a read-only outline link', () => {
|
||||
afterEach(() => window.history.replaceState({}, '', '/'));
|
||||
|
||||
test('shows the outline with no sign-in and a way to switch between outlines', async () => {
|
||||
stubServer({ user: null });
|
||||
window.history.replaceState({}, '', '/?outline=abc123');
|
||||
render(<App />);
|
||||
expect(await screen.findByRole('heading', { name: 'Titus' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Appoint elders')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /sign in/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); // nothing to edit
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Titus 1:1–4' }));
|
||||
expect(await screen.findByRole('heading', { name: 'Titus — Titus 1:1–4' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Greeting')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Appoint elders')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a turned-off or wrong link says so', async () => {
|
||||
stubServer({ user: null });
|
||||
window.history.replaceState({}, '', '/?outline=nope');
|
||||
render(<App />);
|
||||
expect(await screen.findByText(/invalid or has been turned off/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Study cards on Home', () => {
|
||||
test('a long run of chapters and many tags stay short', async () => {
|
||||
const chapters = Array.from({ length: 24 }, (_, i) => `Luke ${i + 1}`).join(', ');
|
||||
const tags = ['A', 'B', 'C', 'D', 'E', 'F'];
|
||||
localStorage.setItem('bible-study-index', JSON.stringify([{ id: 'p', title: 'Luke', lastEdited: Date.now(), chapterSummary: chapters, tags }]));
|
||||
await renderApp();
|
||||
expect(await screen.findByText('Luke 1\u201324')).toBeInTheDocument();
|
||||
expect(screen.getByText('+3 more')).toBeInTheDocument();
|
||||
expect(screen.queryByText('F', { selector: 'span' })).not.toBeInTheDocument(); // only in the Tag filter
|
||||
});
|
||||
});
|
||||
|
||||
describe('Window title', () => {
|
||||
test('names the page you are on, including the chapter in the reader', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
expect(document.title).toBe('My Studies · Bible Study');
|
||||
await user.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||
await waitFor(() => expect(document.title).toBe('Reading Plans · Bible Study'));
|
||||
await user.click(screen.getByRole('button', { name: /read bible/i }));
|
||||
await waitFor(() => expect(document.title).toMatch(/^[A-Za-z0-9 ]+ \d+ · Bible Study$/));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Login page', () => {
|
||||
async function openLogin() {
|
||||
stubServer({ user: null });
|
||||
render(<App />);
|
||||
return screen.findByRole('button', { name: /^sign in$/i });
|
||||
}
|
||||
|
||||
test('tells you what the app can do, in groups', async () => {
|
||||
await openLogin();
|
||||
expect(screen.getByRole('heading', { level: 1, name: /read it\. study it\./i })).toBeInTheDocument();
|
||||
for (const group of ['Read', 'Study', 'Teach', 'Everywhere']) expect(screen.getByRole('region', { name: group })).toBeInTheDocument();
|
||||
expect(screen.getByText('A real outline')).toBeInTheDocument();
|
||||
expect(screen.getByText('Compare them')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('switches between signing in and creating an account', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogin();
|
||||
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /need an account/i }));
|
||||
expect(screen.getByRole('heading', { name: 'Create an account' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/at least 8 characters/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^create account$/i })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /already have an account/i }));
|
||||
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a theme can be picked before signing in', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogin();
|
||||
await user.click(screen.getByRole('button', { name: 'Dark' }));
|
||||
expect(screen.getByRole('button', { name: 'Dark' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(localStorage.getItem('reader-theme')).toBe('dark');
|
||||
});
|
||||
|
||||
test('a wrong password shows the error', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
|
||||
if (url === '/api/auth/me') return json(401, {});
|
||||
if (url === '/api/auth/login') return json(401, { error: 'Incorrect email or password.' });
|
||||
return Promise.resolve({ ok: false });
|
||||
}));
|
||||
render(<App />);
|
||||
await user.type(await screen.findByLabelText(/email/i), 'a@b.co');
|
||||
await user.type(screen.getByLabelText(/^password/i), 'wrong-password');
|
||||
await user.click(screen.getByRole('button', { name: /^sign in$/i }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/incorrect email or password/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
// Admin → Health: is the server in good shape? Disk use, snapshots (make, download, restore) and recent failures.
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { adminBackupNow, adminGetHealth, adminRestoreSnapshot, adminSnapshotUrl } from '../syncService.js';
|
||||
|
||||
const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
|
||||
const card = 'rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4';
|
||||
|
||||
export const formatBytes = (n) => {
|
||||
if (n == null) return 'unknown';
|
||||
if (n < 1024) return `${n} B`;
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let v = n / 1024; let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
|
||||
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
|
||||
};
|
||||
export const formatUptime = (s) => {
|
||||
if (s < 90) return `${s} seconds`;
|
||||
if (s < 5400) return `${Math.round(s / 60)} minutes`;
|
||||
if (s < 129600) return `${Math.round(s / 3600)} hours`;
|
||||
return `${Math.round(s / 86400)} days`;
|
||||
};
|
||||
const ago = (at) => {
|
||||
const m = Math.round((Date.now() - at) / 60000);
|
||||
if (m < 2) return 'just now';
|
||||
if (m < 120) return `${m} minutes ago`;
|
||||
if (m < 2880) return `${Math.round(m / 60)} hours ago`;
|
||||
return `${Math.round(m / 1440)} days ago`;
|
||||
};
|
||||
|
||||
function Log({ title, empty, rows }) {
|
||||
return (
|
||||
<section className={card}>
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{rows.length === 0 ? <p className="mt-2 text-xs text-[var(--r-sub)]">{empty}</p> : (
|
||||
<ul className="mt-2 divide-y divide-[var(--r-line)] text-xs">
|
||||
{rows.map((e, i) => (
|
||||
<li key={`${e.at}-${i}`} className="flex flex-wrap justify-between gap-x-3 py-1.5">
|
||||
<span className="min-w-0 break-words">{e.detail}{e.email && e.kind === 'notify' ? ` (${e.email})` : ''}</span>
|
||||
<span className="shrink-0 text-[var(--r-sub)]">{ago(e.at)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminHealth() {
|
||||
const [state, setState] = useState({ loading: true, error: '', data: null });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [restoring, setRestoring] = useState(null); // snapshot name awaiting typed confirmation
|
||||
const [typed, setTyped] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const r = await adminGetHealth();
|
||||
setState(r.ok ? { loading: false, error: '', data: r.data } : { loading: false, error: r.error ?? 'Could not read server health.', data: null });
|
||||
}, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const backupNow = async () => {
|
||||
setBusy(true); setMessage('');
|
||||
const r = await adminBackupNow();
|
||||
setBusy(false);
|
||||
setMessage(r.ok ? 'Snapshot saved.' : r.error ?? 'The snapshot could not be made.');
|
||||
load();
|
||||
};
|
||||
const restore = async () => {
|
||||
setBusy(true); setMessage('');
|
||||
const r = await adminRestoreSnapshot(restoring);
|
||||
setBusy(false);
|
||||
if (r.ok) { setRestoring(null); setTyped(''); setMessage(`Restored ${r.data.restored}. What was there before is saved as ${r.data.safetyCopy}. You may need to sign in again.`); load(); }
|
||||
else setMessage(r.error ?? 'The snapshot could not be restored.');
|
||||
};
|
||||
|
||||
if (state.loading) return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
|
||||
if (state.error) return <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{state.error}</p>;
|
||||
const { data } = state;
|
||||
const { backups, disk } = data;
|
||||
const stale = backups.enabled && (!backups.latest || Date.now() - backups.latest.at > 36 * 3600 * 1000);
|
||||
const usedPct = disk.total ? Math.round(((disk.total - disk.free) / disk.total) * 100) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className={card}>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||
<div><dt className="text-xs text-[var(--r-sub)]">Running for</dt><dd>{formatUptime(data.uptimeSeconds)}</dd></div>
|
||||
<div><dt className="text-xs text-[var(--r-sub)]">Database</dt><dd>{formatBytes(disk.db)}</dd></div>
|
||||
<div><dt className="text-xs text-[var(--r-sub)]">Snapshots</dt><dd>{formatBytes(disk.backups)}</dd></div>
|
||||
<div><dt className="text-xs text-[var(--r-sub)]">Disk free</dt><dd>{formatBytes(disk.free)}{usedPct != null ? ` (${usedPct}% used)` : ''}</dd></div>
|
||||
</dl>
|
||||
<p className="mt-3 text-xs text-[var(--r-sub)]">Node {data.node} · IQ Bible {data.iqConfigured ? 'connected' : 'not set up'} · data in {data.dataDir}</p>
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">Snapshots</h3>
|
||||
<button type="button" onClick={backupNow} disabled={busy} className={btn}>{busy && !restoring ? 'Working…' : 'Back up now'}</button>
|
||||
</div>
|
||||
{!backups.enabled && <p className="mt-2 text-xs text-[var(--r-danger,#b42318)]">Automatic snapshots are turned off (BACKUP_DISABLED).</p>}
|
||||
{stale && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{backups.latest ? `The last snapshot is ${ago(backups.latest.at)}.` : 'There are no snapshots yet.'} One should be made every day.</p>}
|
||||
{data.backupFailures.length > 0 && <p className="mt-2 text-xs text-[var(--r-danger,#b42318)]">Last failure: {data.backupFailures[0].detail} ({ago(data.backupFailures[0].at)})</p>}
|
||||
{backups.snapshots.length > 0 && (
|
||||
<ul className="mt-2 divide-y divide-[var(--r-line)] text-sm">
|
||||
{backups.snapshots.map((s) => (
|
||||
<li key={s.name} className="flex flex-wrap items-center justify-between gap-2 py-2">
|
||||
<span className="min-w-0"><span className="break-all">{s.name}</span> <span className="text-xs text-[var(--r-sub)]">· {formatBytes(s.bytes)} · {ago(s.at)}</span></span>
|
||||
<span className="flex gap-2">
|
||||
<a href={adminSnapshotUrl(s.name)} download className={btn}>Download</a>
|
||||
<button type="button" onClick={() => { setRestoring(s.name); setTyped(''); setMessage(''); }} className={btn}>Restore…</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-[var(--r-sub)]">Keeps the newest {backups.keep}. A snapshot holds every account’s data.</p>
|
||||
{restoring && (
|
||||
<div role="group" aria-label="Confirm restore" className="mt-3 space-y-2 rounded-xl border border-[var(--r-danger,#b42318)] p-3 text-sm">
|
||||
<p>This replaces <strong>everyone’s</strong> data with what was saved in <span className="break-all">{restoring}</span>. Anything changed since then is lost, though what’s there now is saved as a new snapshot first. Type the snapshot’s name to go ahead.</p>
|
||||
<input value={typed} onChange={(e) => setTyped(e.target.value)} aria-label="Snapshot name" placeholder={restoring}
|
||||
className="w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs" />
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={restore} disabled={busy || typed !== restoring} className="rounded-lg bg-[var(--r-danger,#b42318)] px-3 py-1.5 text-xs font-medium text-white disabled:opacity-40">{busy ? 'Restoring…' : 'Restore this snapshot'}</button>
|
||||
<button type="button" onClick={() => setRestoring(null)} className={btn}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{message && <p role="status" className="mt-2 text-xs text-[var(--r-sub)]">{message}</p>}
|
||||
</section>
|
||||
|
||||
<Log title="Failed sign-ins" empty="None since the server last started." rows={data.signinFailures} />
|
||||
<Log title="Failed notifications" empty="None since the server last started." rows={data.notifyFailures} />
|
||||
<p className="text-xs text-[var(--r-sub)]">The failure lists are kept in memory, so they start over when the server restarts.</p>
|
||||
<button type="button" onClick={load} className={btn}>Refresh</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import AdminHealth, { formatBytes, formatUptime } from './AdminHealth.jsx';
|
||||
|
||||
const health = (over = {}) => ({
|
||||
uptimeSeconds: 7200, node: 'v22', dataDir: '/data', iqConfigured: false,
|
||||
disk: { db: 2048, backups: 4096, free: 5e9, total: 1e10 },
|
||||
backups: { enabled: true, keep: 7, latest: { name: 'projects-2026-10-05-03-00.db', bytes: 2048, at: Date.now() - 3600000 }, snapshots: [{ name: 'projects-2026-10-05-03-00.db', bytes: 2048, at: Date.now() - 3600000 }] },
|
||||
signinFailures: [{ at: Date.now() - 60000, kind: 'signin', detail: 'Wrong password for a@x.test from 1.2.3.4' }],
|
||||
notifyFailures: [], backupFailures: [], ...over,
|
||||
});
|
||||
const mockFetch = (routes) => vi.stubGlobal('fetch', vi.fn(async (url, opts) => {
|
||||
const key = `${opts?.method ?? 'GET'} ${String(url).replace(/^.*\/api/, '')}`;
|
||||
const r = routes[key] ?? routes.default;
|
||||
return { ok: r.ok ?? true, status: r.status ?? 200, json: async () => r.body };
|
||||
}));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
test('formats sizes and times plainly', () => {
|
||||
expect(formatBytes(2048)).toBe('2.0 KB');
|
||||
expect(formatBytes(5e9)).toBe('4.7 GB');
|
||||
expect(formatBytes(null)).toBe('unknown');
|
||||
expect(formatUptime(7200)).toBe('2 hours');
|
||||
});
|
||||
|
||||
test('shows disk use, snapshots and sign-in failures', async () => {
|
||||
mockFetch({ 'GET /admin/health': { body: health() } });
|
||||
render(<AdminHealth />);
|
||||
expect(await screen.findByText('2 hours')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Wrong password for a@x.test/)).toBeInTheDocument();
|
||||
expect(screen.getByText('None since the server last started.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Download' })).toHaveAttribute('href', expect.stringContaining('/admin/backups/projects-2026-10-05-03-00.db'));
|
||||
});
|
||||
|
||||
test('warns when the newest snapshot is old', async () => {
|
||||
const old = Date.now() - 4 * 86400000;
|
||||
mockFetch({ 'GET /admin/health': { body: health({ backups: { enabled: true, keep: 7, latest: { name: 'x', bytes: 1, at: old }, snapshots: [] } }) } });
|
||||
render(<AdminHealth />);
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/last snapshot is 4 days ago/);
|
||||
});
|
||||
|
||||
test('restoring needs the snapshot name typed first', async () => {
|
||||
const name = 'projects-2026-10-05-03-00.db';
|
||||
mockFetch({ 'GET /admin/health': { body: health() }, [`POST /admin/backups/${name}/restore`]: { body: { restored: name, safetyCopy: 'projects-new.db' } } });
|
||||
const user = userEvent.setup();
|
||||
render(<AdminHealth />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Restore…' }));
|
||||
const go = screen.getByRole('button', { name: 'Restore this snapshot' });
|
||||
expect(go).toBeDisabled();
|
||||
await user.type(screen.getByLabelText('Snapshot name'), name);
|
||||
expect(go).toBeEnabled();
|
||||
await user.click(go);
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Restored projects-2026-10-05-03-00.db'));
|
||||
expect(fetch.mock.calls.some(([u, o]) => o?.method === 'POST' && JSON.parse(o.body).confirm === name)).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// Admin → Sign-ups: who may create an account, and invite codes for when it is invite-only.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { adminCreateInvite, adminDeleteInvite, adminGetSignup, adminSetSignupMode } from '../syncService.js';
|
||||
|
||||
const MODES = [
|
||||
['open', 'Open', 'Anyone who can reach this site can create an account.'],
|
||||
['invite', 'Invite only', 'A new account needs an invite code from you.'],
|
||||
['closed', 'Closed', 'Nobody new can sign up.'],
|
||||
];
|
||||
const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
|
||||
|
||||
export default function AdminSignup() {
|
||||
const [state, setState] = useState({ mode: 'open', invites: [], loading: true, error: '' });
|
||||
const [note, setNote] = useState('');
|
||||
const [days, setDays] = useState(7);
|
||||
const [copied, setCopied] = useState('');
|
||||
|
||||
const load = async () => {
|
||||
const r = await adminGetSignup();
|
||||
setState(r.ok ? { ...r.data, loading: false, error: '' } : { mode: 'open', invites: [], loading: false, error: r.error ?? 'Could not load sign-up settings.' });
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const setMode = async (mode) => {
|
||||
const r = await adminSetSignupMode(mode);
|
||||
if (r.ok) setState((s) => ({ ...s, mode, error: '' })); else setState((s) => ({ ...s, error: r.error ?? 'Could not change that.' }));
|
||||
};
|
||||
const create = async () => {
|
||||
const r = await adminCreateInvite({ note, days });
|
||||
if (!r.ok) { setState((s) => ({ ...s, error: r.error ?? 'Could not make an invite.' })); return; }
|
||||
setNote('');
|
||||
load();
|
||||
};
|
||||
const link = (code) => `${window.location.origin}/?invite=${code}`;
|
||||
const copy = async (code) => {
|
||||
try { await navigator.clipboard.writeText(link(code)); setCopied(code); window.setTimeout(() => setCopied(''), 1800); } catch { /* clipboard unavailable */ }
|
||||
};
|
||||
const status = (inv) => (inv.usedBy ? 'Used' : inv.expiresAt < Date.now() ? 'Expired' : `Until ${new Date(inv.expiresAt).toLocaleDateString()}`);
|
||||
|
||||
if (state.loading) return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Who can sign up">
|
||||
<h2 className="text-base font-medium">Who can sign up</h2>
|
||||
<div role="radiogroup" aria-label="Sign-up setting" className="mt-3 space-y-2">
|
||||
{MODES.map(([id, label, hint]) => (
|
||||
<label key={id} className="flex cursor-pointer items-start gap-3 text-sm">
|
||||
<input type="radio" name="signup-mode" checked={state.mode === id} onChange={() => setMode(id)} className="mt-1 accent-[var(--r-accent)]" />
|
||||
<span><span className="font-medium">{label}</span><span className="block text-xs text-[var(--r-sub)]">{hint}</span></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{state.error && <p role="alert" className="mt-3 text-sm text-[var(--r-danger,#b42318)]">{state.error}</p>}
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Invite codes">
|
||||
<h2 className="text-base font-medium">Invite codes</h2>
|
||||
<p className="mt-1 text-xs text-[var(--r-sub)]">Each code works once. Send the link, and it opens the sign-up page with the code filled in.{state.mode !== 'invite' ? ' (Codes only matter while sign-up is “Invite only”.)' : ''}</p>
|
||||
<div className="mt-3 flex flex-wrap items-end gap-2">
|
||||
<label className="text-xs text-[var(--r-sub)]">Who is it for (optional)
|
||||
<input type="text" value={note} onChange={(e) => setNote(e.target.value)} maxLength={80} className="mt-1 block w-52 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]" />
|
||||
</label>
|
||||
<label className="text-xs text-[var(--r-sub)]">Good for
|
||||
<select value={days} onChange={(e) => setDays(Number(e.target.value))} className="mt-1 block rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
|
||||
{[1, 7, 30].map((d) => <option key={d} value={d}>{d} day{d === 1 ? '' : 's'}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" onClick={create} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90">Make an invite</button>
|
||||
</div>
|
||||
{state.invites.length > 0 && (
|
||||
<ul className="mt-4 divide-y divide-[var(--r-line)] text-sm">
|
||||
{state.invites.map((inv) => (
|
||||
<li key={inv.code} className="flex flex-wrap items-center justify-between gap-2 py-2">
|
||||
<span><span className="font-mono tracking-wider">{inv.code}</span>{inv.note ? <span className="ml-2 text-[var(--r-sub)]">{inv.note}</span> : null}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--r-sub)]">{status(inv)}</span>
|
||||
{!inv.usedBy && inv.expiresAt >= Date.now() && <button type="button" onClick={() => copy(inv.code)} className={btn}>{copied === inv.code ? 'Copied' : 'Copy link'}</button>}
|
||||
<button type="button" onClick={async () => { await adminDeleteInvite(inv.code); load(); }} className={`${btn} text-[var(--r-danger,#b42318)]`}>Delete</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// The audience window for two-screen presenting: drag it to the projector or second display and press F (or click)
|
||||
// for full screen. It only draws what the presenter window sends, which never includes private notes.
|
||||
import { useEffect, useState } from 'react';
|
||||
import StepFace from './StepFace.jsx';
|
||||
import { openChannel } from '../utils/presentSync.js';
|
||||
|
||||
export default function AudienceView() {
|
||||
const [state, setState] = useState(null);
|
||||
const [ended, setEnded] = useState(false);
|
||||
const [hint, setHint] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Audience display';
|
||||
const channel = openChannel();
|
||||
if (!channel) return undefined;
|
||||
channel.onmessage = (e) => {
|
||||
if (e.data?.type === 'state') { setState(e.data); setEnded(false); }
|
||||
else if (e.data?.type === 'end') setEnded(true);
|
||||
};
|
||||
channel.postMessage({ type: 'hello' }); // ask a presenter already running to send what it is showing
|
||||
return () => channel.close();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fullscreen = () => { if (document.fullscreenElement) document.exitFullscreen?.(); else document.documentElement.requestFullscreen?.().catch(() => {}); };
|
||||
const onKey = (e) => { if (e.key === 'f' || e.key === 'F') fullscreen(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
const timer = window.setTimeout(() => setHint(false), 8000);
|
||||
return () => { window.removeEventListener('keydown', onKey); window.clearTimeout(timer); };
|
||||
}, []);
|
||||
|
||||
const dark = state?.dark ?? true;
|
||||
const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]';
|
||||
const fontSize = `${state?.fontPx ?? 40}px`;
|
||||
let body;
|
||||
if (!state || ended) {
|
||||
body = <p className="text-center text-lg opacity-60">{ended ? 'The presentation has ended.' : 'Waiting for the presenter…'}</p>;
|
||||
} else if (state.blank) {
|
||||
body = null;
|
||||
} else if (state.mode === 'outline' && state.step) {
|
||||
body = <StepFace step={state.step} index={state.index} total={state.total} fontSize={fontSize} />;
|
||||
} else {
|
||||
body = (
|
||||
<div className="text-center font-serif" style={{ fontSize }}>
|
||||
<h1 className="font-semibold">{state.title}</h1>
|
||||
{state.subtitle && <p className="mt-3 text-[0.55em] opacity-70">{state.subtitle}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`fixed inset-0 flex cursor-none items-center justify-center overflow-hidden px-[6vw] py-[6dvh] ${state?.blank ? 'bg-black' : colors}`} onClick={() => document.documentElement.requestFullscreen?.().catch(() => {})}>
|
||||
{body}
|
||||
{hint && !state?.blank && <p className="fixed bottom-3 left-0 right-0 text-center font-sans text-xs opacity-40">Press F or click for full screen</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||