Compare commits
2
Commits
d29a66f984
...
9893883b19
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9893883b19 | ||
|
|
31ac6b9179 |
No files matched your search
@@ -11,7 +11,7 @@ Ideas for the app that haven't been built yet, roughly in the order I'd do them.
|
|||||||
"restore a snapshot" button (snapshots already exist under `DATA_DIR/backups`).
|
"restore a snapshot" button (snapshots already exist under `DATA_DIR/backups`).
|
||||||
- [ ] **Checks on every push.** Run tests and lint on the Gitea server, plus a few real-browser tests (Playwright) for sign-in,
|
- [ ] **Checks on every push.** Run tests and lint on the Gitea server, plus a few real-browser tests (Playwright) for sign-in,
|
||||||
creating a study and syncing; so far everything is tested in simulation.
|
creating a study and syncing; so far everything is tested in simulation.
|
||||||
- [ ] **Split `server/index.js`** (now about 1,100 lines) into one file per area (auth, projects, sharing, reader data, admin).
|
- [x] **Split `server/index.js`** into one file per area (`server/routes/`: auth, account, projects, reader, sharing, admin).
|
||||||
|
|
||||||
## Teaching together
|
## Teaching together
|
||||||
- [ ] **Comments between co-teachers.** Notes on a chunk addressed to the other person, instead of editing each other's text.
|
- [ ] **Comments between co-teachers.** Notes on a chunk addressed to the other person, instead of editing each other's text.
|
||||||
|
|||||||
+22
-1039
File diff suppressed because it is too large.
Load diff
+21
-4
@@ -89,6 +89,24 @@ async function cacheTtl(headers, isText) {
|
|||||||
const cache = new Map(); // key -> { at, status, type, body }
|
const cache = new Map(); // key -> { at, status, type, body }
|
||||||
const CACHE_MAX = 1500;
|
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 }
|
const hits = new Map(); // `${userId}:${bucket}` -> { n, resetAt }
|
||||||
function overLimit(userId, heavy) {
|
function overLimit(userId, heavy) {
|
||||||
const bucket = heavy ? 'h' : 'n';
|
const bucket = heavy ? 'h' : 'n';
|
||||||
@@ -119,12 +137,11 @@ export function registerIqBibleRoutes(app, requireAuth) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const upstream = await fetch(`${BASE()}${target.key}`, { headers, signal: AbortSignal.timeout(20_000) });
|
const upstream = await fetchOnce(target.key, headers);
|
||||||
const body = Buffer.from(await upstream.arrayBuffer());
|
const { body, type } = upstream;
|
||||||
const type = upstream.headers.get('content-type') || 'application/json';
|
|
||||||
if (upstream.status === 401) return res.status(502).json({ error: 'The IQ Bible key was rejected. Check IQBIBLE_API_KEY.' });
|
if (upstream.status === 401) return res.status(502).json({ error: 'The IQ Bible key was rejected. Check IQBIBLE_API_KEY.' });
|
||||||
if (upstream.status === 429) {
|
if (upstream.status === 429) {
|
||||||
const retry = upstream.headers.get('retry-after');
|
const retry = upstream.retryAfter;
|
||||||
if (retry) res.set('Retry-After', retry);
|
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.' });
|
return res.status(429).json({ error: 'The IQ Bible allowance is used up for now. Try again later.' });
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-1
@@ -1,5 +1,5 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
import { test, expect } from 'vitest';
|
import { test, expect, vi } from 'vitest';
|
||||||
import { resolveIqPath } from './iqbible.js';
|
import { resolveIqPath } from './iqbible.js';
|
||||||
|
|
||||||
test('only listed IQ Bible routes are reachable', () => {
|
test('only listed IQ Bible routes are reachable', () => {
|
||||||
@@ -32,3 +32,30 @@ test('only verse and chapter text counts as cacheable text', async () => {
|
|||||||
expect(isTextPath('/commentaries/gill/JHN/3')).toBe(false);
|
expect(isTextPath('/commentaries/gill/JHN/3')).toBe(false);
|
||||||
expect(resolveIqPath('/bibles/parallel', { v: 'eng_kjv,spa_rv' })).not.toBeNull();
|
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,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,150 @@
|
|||||||
|
// 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 } 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 });
|
||||||
|
} 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;
|
||||||
|
setShareSettings(req.params.id, userId, { expiresAt, passHash }, outline);
|
||||||
|
res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash });
|
||||||
|
} 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.project;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 project = await openSharedLink(req, res, true);
|
||||||
|
if (project) res.json(buildOutlinePayload(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 project = await openSharedLink(req, res, false);
|
||||||
|
if (project) res.json(project);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/share/:token error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load shared project.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
// <option>s for IQ Bible's narrated Bibles, to add inside an audio narrator <select>. Playing a
|
// <option>s for IQ Bible's narrated Bibles, to add inside an audio narrator <select>. Playing a
|
||||||
// chapter this way uses one of the account's monthly audio streams, so the group says so.
|
// chapter this way uses one of the account's monthly audio streams, so the group says so.
|
||||||
|
import { useState } from 'react';
|
||||||
import { useIq, useIqAvailable } from '../../hooks/useIq.js';
|
import { useIq, useIqAvailable } from '../../hooks/useIq.js';
|
||||||
import { listOf } from '../../utils/iqbible.js';
|
import { listOf } from '../../utils/iqbible.js';
|
||||||
|
|
||||||
export default function IqNarratorOptions() {
|
// `enabled` holds the request back until someone opens the list (or an IQ narration is already chosen).
|
||||||
|
export default function IqNarratorOptions({ enabled = true }) {
|
||||||
const on = useIqAvailable();
|
const on = useIqAvailable();
|
||||||
const { data } = useIq(on ? 'audio' : null);
|
const { data } = useIq(on && enabled ? 'audio' : null);
|
||||||
const sets = listOf(data).filter((s) => s.active !== false);
|
const sets = listOf(data).filter((s) => s.active !== false);
|
||||||
if (!on || sets.length === 0) return null;
|
if (!on || sets.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
@@ -14,3 +16,10 @@ export default function IqNarratorOptions() {
|
|||||||
</optgroup>
|
</optgroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For the <select> that holds these options: props to spread on it, and the `enabled` flag to pass along.
|
||||||
|
export function useLazyNarrators(value) {
|
||||||
|
const [opened, setOpened] = useState(false);
|
||||||
|
const open = () => setOpened(true);
|
||||||
|
return { enabled: opened || String(value ?? '').startsWith('iq:'), selectProps: { onFocus: open, onMouseDown: open, onTouchStart: open } };
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { useApp } from '../context/AppContext.js';
|
|||||||
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
|
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
|
||||||
import BookCover from '../components/BookCover.jsx';
|
import BookCover from '../components/BookCover.jsx';
|
||||||
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
|
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
|
||||||
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
|
import IqNarratorOptions, { useLazyNarrators } from '../components/iq/IqNarratorOptions.jsx';
|
||||||
import PageShell from '../components/study/PageShell.jsx';
|
import PageShell from '../components/study/PageShell.jsx';
|
||||||
import AudioPlayer from '../components/AudioPlayer.jsx';
|
import AudioPlayer from '../components/AudioPlayer.jsx';
|
||||||
import {
|
import {
|
||||||
@@ -49,6 +49,7 @@ export default function HomePage() {
|
|||||||
handleSeekAudio,
|
handleSeekAudio,
|
||||||
handleSeekAudioTo,
|
handleSeekAudioTo,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
|
const narrators = useLazyNarrators(audioNarrator);
|
||||||
const [homeSearch, setHomeSearch] = useState('');
|
const [homeSearch, setHomeSearch] = useState('');
|
||||||
const [homeSort, setHomeSort] = useState(() => {
|
const [homeSort, setHomeSort] = useState(() => {
|
||||||
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
|
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
|
||||||
@@ -343,11 +344,11 @@ export default function HomePage() {
|
|||||||
<select value={audioBook} onChange={(e) => setAudioBook(e.target.value)} disabled={audioState.status === 'playing' || audioState.status === 'paused'} className={fieldCls}>
|
<select value={audioBook} onChange={(e) => setAudioBook(e.target.value)} disabled={audioState.status === 'playing' || audioState.status === 'paused'} className={fieldCls}>
|
||||||
{bookOptions.map((book) => <option key={book.abbrev} value={book.abbrev}>{book.name}</option>)}
|
{bookOptions.map((book) => <option key={book.abbrev} value={book.abbrev}>{book.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<select value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} disabled={audioState.status === 'playing' || audioState.status === 'paused'} className={fieldCls}>
|
<select value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} disabled={audioState.status === 'playing' || audioState.status === 'paused'} className={fieldCls} {...narrators.selectProps}>
|
||||||
<option value="david">David</option>
|
<option value="david">David</option>
|
||||||
<option value="hays">Hays</option>
|
<option value="hays">Hays</option>
|
||||||
<option value="souer">Souer</option>
|
<option value="souer">Souer</option>
|
||||||
<IqNarratorOptions />
|
<IqNarratorOptions enabled={narrators.enabled} />
|
||||||
</select>
|
</select>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { normalizeNtfyServer } from '../utils/ntfyServer.js';
|
import { normalizeNtfyServer } from '../utils/ntfyServer.js';
|
||||||
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
|
import IqNarratorOptions, { useLazyNarrators } from '../components/iq/IqNarratorOptions.jsx';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import PageShell from '../components/study/PageShell.jsx';
|
import PageShell from '../components/study/PageShell.jsx';
|
||||||
@@ -65,6 +65,7 @@ export default function SettingsPage() {
|
|||||||
} = useApp();
|
} = useApp();
|
||||||
|
|
||||||
// "Back up my data" from the ⌘K palette lands on that section
|
// "Back up my data" from the ⌘K palette lands on that section
|
||||||
|
const narrators = useLazyNarrators(audioNarrator);
|
||||||
const [tab, setTab] = useState(settingsFocus === 'backup' ? 'data' : 'account');
|
const [tab, setTab] = useState(settingsFocus === 'backup' ? 'data' : 'account');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settingsFocus) return;
|
if (!settingsFocus) return;
|
||||||
@@ -290,11 +291,11 @@ export default function SettingsPage() {
|
|||||||
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={readerWide} onChange={setReaderWide} />
|
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={readerWide} onChange={setReaderWide} />
|
||||||
</PrefRow>
|
</PrefRow>
|
||||||
<PrefRow label="Audio narrator" hint="For the Berean Standard Bible audio. IQ Bible narrations count against its monthly audio allowance.">
|
<PrefRow label="Audio narrator" hint="For the Berean Standard Bible audio. IQ Bible narrations count against its monthly audio allowance.">
|
||||||
<select aria-label="Audio narrator" value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} className={selectCls}>
|
<select aria-label="Audio narrator" value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} className={selectCls} {...narrators.selectProps}>
|
||||||
<option value="david">David</option>
|
<option value="david">David</option>
|
||||||
<option value="hays">Hays</option>
|
<option value="hays">Hays</option>
|
||||||
<option value="souer">Souer</option>
|
<option value="souer">Souer</option>
|
||||||
<IqNarratorOptions />
|
<IqNarratorOptions enabled={narrators.enabled} />
|
||||||
</select>
|
</select>
|
||||||
</PrefRow>
|
</PrefRow>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+16
-4
@@ -2,7 +2,12 @@
|
|||||||
// asked for; here we only build requests and keep answers for a few minutes so one open panel doesn't
|
// asked for; here we only build requests and keep answers for a few minutes so one open panel doesn't
|
||||||
// repeat the same call. (Longer caching is the server's job and follows the API's licence terms.)
|
// repeat the same call. (Longer caching is the server's job and follows the API's licence terms.)
|
||||||
const MEMO_MS = 5 * 60 * 1000;
|
const MEMO_MS = 5 * 60 * 1000;
|
||||||
const memo = new Map(); // key -> { at, promise }
|
// Today's verse and devotional are the same all day for everyone, so asking again within the hour is wasted.
|
||||||
|
// (Held in this tab's memory only; never saved.)
|
||||||
|
const DAILY = /\/(?:verse-of-the-day|devotionals\/today)(?:\?|$)/;
|
||||||
|
const DAILY_MS = 60 * 60 * 1000;
|
||||||
|
const FAILED_MS = 60 * 1000; // after "allowance used up" or "unreachable", don't ask again straight away
|
||||||
|
const memo = new Map(); // key -> { at, promise, ttl }
|
||||||
let availability = null;
|
let availability = null;
|
||||||
|
|
||||||
export function iqAvailable() {
|
export function iqAvailable() {
|
||||||
@@ -27,7 +32,7 @@ export const iqUrl = buildPath;
|
|||||||
export function iqGet(path, query, { fresh = false } = {}) {
|
export function iqGet(path, query, { fresh = false } = {}) {
|
||||||
const url = buildPath(path, query);
|
const url = buildPath(path, query);
|
||||||
const hit = memo.get(url);
|
const hit = memo.get(url);
|
||||||
if (!fresh && hit && Date.now() - hit.at < MEMO_MS) return hit.promise;
|
if (!fresh && hit && Date.now() - hit.at < hit.ttl) return hit.promise;
|
||||||
const promise = fetch(url, { credentials: 'include' })
|
const promise = fetch(url, { credentials: 'include' })
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
@@ -35,8 +40,15 @@ export function iqGet(path, query, { fresh = false } = {}) {
|
|||||||
return { ok: true, data };
|
return { ok: true, data };
|
||||||
})
|
})
|
||||||
.catch(() => ({ ok: false, status: null, error: 'Network error' }));
|
.catch(() => ({ ok: false, status: null, error: 'Network error' }));
|
||||||
memo.set(url, { at: Date.now(), promise });
|
const entry = { at: Date.now(), promise, ttl: DAILY.test(url) ? DAILY_MS : MEMO_MS };
|
||||||
promise.then((r) => { if (!r.ok) memo.delete(url); });
|
memo.set(url, entry);
|
||||||
|
promise.then((r) => {
|
||||||
|
if (r.ok) return;
|
||||||
|
// "Nothing here" (404) is a real answer and is kept like any other; a busy or unreachable service gets a
|
||||||
|
// minute's rest; anything else (a refused request) is forgotten so it can be tried again.
|
||||||
|
if (r.status === 429 || r.status === null || r.status >= 500) entry.ttl = FAILED_MS;
|
||||||
|
else if (r.status !== 404) memo.delete(url);
|
||||||
|
});
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||||
|
import { iqGet, resetIq } from './iqbible.js';
|
||||||
|
|
||||||
|
const answer = (status, body = {}) => ({ ok: status < 400, status, json: async () => body });
|
||||||
|
beforeEach(() => { resetIq(); vi.useFakeTimers(); });
|
||||||
|
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
|
||||||
|
|
||||||
|
test('the same question is asked once, and the answer is reused for a few minutes', async () => {
|
||||||
|
const f = vi.fn(async () => answer(200, { data: 1 })); vi.stubGlobal('fetch', f);
|
||||||
|
await Promise.all([iqGet('geo/JHN/3'), iqGet('geo/JHN/3')]);
|
||||||
|
await iqGet('geo/JHN/3');
|
||||||
|
expect(f).toHaveBeenCalledTimes(1);
|
||||||
|
vi.advanceTimersByTime(6 * 60 * 1000);
|
||||||
|
await iqGet('geo/JHN/3');
|
||||||
|
expect(f).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("today's verse and devotional are reused for the hour", async () => {
|
||||||
|
const f = vi.fn(async () => answer(200, { data: {} })); vi.stubGlobal('fetch', f);
|
||||||
|
await iqGet('bibles/eng_kjv/verse-of-the-day'); await iqGet('devotionals/today');
|
||||||
|
vi.advanceTimersByTime(50 * 60 * 1000);
|
||||||
|
await iqGet('bibles/eng_kjv/verse-of-the-day'); await iqGet('devotionals/today');
|
||||||
|
expect(f).toHaveBeenCalledTimes(2);
|
||||||
|
vi.advanceTimersByTime(11 * 60 * 1000);
|
||||||
|
await iqGet('devotionals/today');
|
||||||
|
expect(f).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('after the allowance runs out it waits a minute, but "nothing here" is kept like any answer', async () => {
|
||||||
|
const busy = vi.fn(async () => answer(429, { error: 'used up' })); vi.stubGlobal('fetch', busy);
|
||||||
|
expect((await iqGet('topics/JHN/3/16')).status).toBe(429);
|
||||||
|
await iqGet('topics/JHN/3/16');
|
||||||
|
expect(busy).toHaveBeenCalledTimes(1);
|
||||||
|
vi.advanceTimersByTime(61_000);
|
||||||
|
await iqGet('topics/JHN/3/16');
|
||||||
|
expect(busy).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const none = vi.fn(async () => answer(404, { error: 'none' })); vi.stubGlobal('fetch', none);
|
||||||
|
await iqGet('geo/JHN/1'); await iqGet('geo/JHN/1');
|
||||||
|
expect(none).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const refused = vi.fn(async () => answer(400, { error: 'bad' })); vi.stubGlobal('fetch', refused);
|
||||||
|
await iqGet('geo/JHN/2'); await iqGet('geo/JHN/2');
|
||||||
|
expect(refused).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user