Harden sync and security; add IQ Bible, templates, password reset, backups
Build and Push Docker Image / build-and-push (push) Successful in 7m14s

Sync and data safety
- Autosave flushes on leaving a study or the tab; storage-full errors are reported
- Unsynced studies, deletes and ink are queued and retried when back online
- Deletes propagate across devices (with a "Keep it" undo) and no longer resurrect
- Server assigns study versions (no client-clock conflicts); "Combine both"
  merges a conflicted study chunk by chunk
- Reading-plan progress made offline is merged, not overwritten; plans, ink and
  marks are cleared from the device on sign-out

Security
- Block SSRF through the ntfy reminder URL; failure-only rate limiting per IP and
  account; constant-time login; TOTP replay protection; input validation;
  SESSION_SECRET required in production; admin user delete fixed
- Forgot password: one-time code sent to the account's ntfy topic

Features
- IQ Bible proxy (route allow-list, text-only caching per its terms): more
  commentaries, lexicons, study aids, daily verse and devotional, plan calendar
  export, narrations, podcast background
- Study templates, reading-plan catch-up, account export, daily database snapshots

Tests and tooling
- ESLint config; App tests split into src/test; tests for sync queues, library
  hook, merge, templates, IQ Bible UI and reset form

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 08:13:36 -04:00
1 parent 6bb392933a
commit f274bc9c8a
67 files changed
+4734 -1569

No files matched your search

+89 -29
View File
@@ -22,6 +22,9 @@ import {
bookOptions, buildExportHtml, loadProjectIndex, makeId, parseBibleChapter, switchStorageUser,
} from './utils/bibleUtils.jsx';
import { loadProjectById, migrateOldStorageKeys, saveProjectToStorage } from './utils/projectStorage.js';
import { knownOnServer, unsyncedStudies } from './utils/syncQueue.js';
import { fetchStudyChapter } from './utils/studyText.js';
import { mergeStudies } from './utils/mergeStudies.js';
// Less-common pages are split into their own chunks so the first load only
// downloads what's needed to show the home screen.
@@ -100,6 +103,8 @@ const AppInner = () => {
onSignOut: () => {
reader.stopMarksSync();
reader.stopPrefsSync();
reader.stopInkSync();
readingPlans.stopReadingPlansSync();
setProjectIndex(switchStorageUser(null));
goStudies();
},
@@ -163,10 +168,12 @@ const AppInner = () => {
const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : [];
// Autosave: 1s after the last edit, save locally, then sync to the server.
// Autosave: 1s after the last edit, save locally, then sync to the server. A save still waiting out
// that second is written straight away when you leave the study or the tab, so nothing typed is lost.
// Opening a study doesn't count as editing it — saving then would stamp the server's copy as "newer"
// and make your other devices think it changed.
const savedSignatureRef = useRef({ id: null, signature: '' });
const pendingSaveRef = useRef(null); // { project, signature } waiting for the debounce
const [syncConflict, setSyncConflict] = useState(null); // { id, serverEdited } when the server has a newer copy
const conflictRef = useRef(null);
conflictRef.current = syncConflict;
@@ -177,7 +184,15 @@ const AppInner = () => {
setSyncStatus('syncing');
const result = await saveRemoteProject(toSave, { baseEdited: getSyncBase(toSave.id), force });
if (result.ok) {
setSyncBase(toSave.id, result.data?.lastEdited ?? toSave.lastEdited);
// The server's clock decides the version; keep this device's copy on the same number
const serverEdited = result.data?.lastEdited ?? toSave.lastEdited;
setSyncBase(toSave.id, serverEdited);
unsyncedStudies.remove(toSave.id);
knownOnServer.add(toSave.id);
const stored = loadProjectById(toSave.id);
if (stored && stored.lastEdited === toSave.lastEdited && serverEdited !== toSave.lastEdited) {
try { saveProjectToStorage({ ...stored, lastEdited: serverEdited }); setProjectIndex(loadProjectIndex()); } catch { /* the copy is already saved */ }
}
setSyncConflict((c) => (c?.id === toSave.id ? null : c));
setSyncStatus('synced');
window.setTimeout(() => setSyncStatus(''), 2500);
@@ -190,8 +205,49 @@ const AppInner = () => {
return result;
};
const commitSave = async (toEdit) => {
// Stamp lastEdited once so localStorage and server receive the same value
const toSave = { ...toEdit, lastEdited: Date.now() };
// 1. Save locally (can fail when the device is out of storage)
try {
saveProjectToStorage(toSave);
} catch (err) {
console.error('Local save failed:', err);
setSaveStatus('');
toast("Couldn't save on this device — storage is full. Free some space or export a backup.", { tone: 'error' });
}
unsyncedStudies.add(toSave.id);
setProjectIndex(loadProjectIndex());
setSaveStatus('Saved');
window.setTimeout(() => setSaveStatus(''), 1400);
// 2. Sync to server with the same timestamped object (not while a conflict is waiting on a choice)
if (conflictRef.current?.id === toSave.id) return;
await pushProject(toSave);
};
const flushPendingSave = () => {
const pending = pendingSaveRef.current;
if (!pending) return;
window.clearTimeout(saveTimerRef.current);
pendingSaveRef.current = null;
savedSignatureRef.current = { id: pending.project.id, signature: pending.signature };
commitSave(pending.project);
};
const flushRef = useRef(flushPendingSave);
flushRef.current = flushPendingSave;
useEffect(() => {
if (!project) return undefined;
const flush = () => { if (document.visibilityState !== 'visible') flushRef.current(); };
window.addEventListener('pagehide', flush);
document.addEventListener('visibilitychange', flush);
return () => { window.removeEventListener('pagehide', flush); document.removeEventListener('visibilitychange', flush); };
}, []);
useEffect(() => {
if (!project) { flushPendingSave(); return undefined; }
if (pendingSaveRef.current && pendingSaveRef.current.project.id !== project.id) flushPendingSave();
const signature = signatureOf(project);
const last = savedSignatureRef.current;
if (last.id !== project.id) {
@@ -200,25 +256,10 @@ const AppInner = () => {
} else if (last.signature === signature) {
return undefined;
}
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = window.setTimeout(async () => {
savedSignatureRef.current = { id: project.id, signature };
// Stamp lastEdited once so localStorage and server receive the same value
const toSave = { ...project, lastEdited: Date.now() };
// 1. Save locally
saveProjectToStorage(toSave);
setProjectIndex(loadProjectIndex());
setSaveStatus('Saved');
window.setTimeout(() => setSaveStatus(''), 1400);
// 2. Sync to server with the same timestamped object (not while a conflict is waiting on a choice)
if (conflictRef.current?.id === toSave.id) return;
await pushProject(toSave);
}, 1000);
return () => {
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
};
pendingSaveRef.current = { project, signature };
window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = window.setTimeout(() => flushRef.current(), 1000);
return undefined;
}, [project]); // eslint-disable-line react-hooks/exhaustive-deps
// The two ways out of a conflict: keep this device's version, or take the server's.
@@ -232,6 +273,7 @@ const AppInner = () => {
if (!result.ok) { toast("Couldn't load the other version. Check your connection and try again.", { tone: 'error' }); return; }
saveProjectToStorage(result.data);
setSyncBase(syncConflict.id, result.data.lastEdited);
unsyncedStudies.remove(syncConflict.id);
const loaded = loadProjectById(syncConflict.id);
savedSignatureRef.current = { id: syncConflict.id, signature: signatureOf(loaded) };
setProject(loaded);
@@ -241,6 +283,25 @@ const AppInner = () => {
toast('Switched to the version from your other device');
};
// Third way out of a conflict: combine both copies chunk by chunk, then save the result as the new version.
const mergeBothVersions = async () => {
if (!project || !syncConflict) return;
const result = await loadRemoteProject(syncConflict.id);
if (!result.ok) { toast("Couldn't load the other version. Check your connection and try again.", { tone: 'error' }); return; }
const { project: merged, stats } = mergeStudies(project, result.data);
setSyncBase(syncConflict.id, result.data.lastEdited);
savedSignatureRef.current = { id: merged.id, signature: signatureOf(merged) };
setProject(merged);
const toSave = { ...merged, lastEdited: Date.now() };
try { saveProjectToStorage(toSave); } catch { /* storage full: the server copy below still holds it */ }
unsyncedStudies.add(toSave.id);
setProjectIndex(loadProjectIndex());
const saved = await pushProject(toSave);
if (saved.ok) {
toast(`Combined both versions: ${stats.added} chunk${stats.added === 1 ? '' : 's'} added from the other device, ${stats.merged} merged`);
}
};
const updateProject = (updater) => {
setProject((current) => (current ? updater(current) : current));
};
@@ -250,7 +311,7 @@ const AppInner = () => {
...current,
chapters: current.chapters.map((ch) => ({
...ch,
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch } : c)),
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch, editedAt: Date.now() } : c)),
})),
}));
};
@@ -309,9 +370,7 @@ const AppInner = () => {
setStudyBuildBusy(true);
try {
const chapters = await Promise.all(dayRefs.map(async ({ book, chapter }) => {
const res = await fetch(`https://bible.helloao.org/api/BSB/${book}/${chapter}.json`);
if (!res.ok) throw new Error(`Failed to load ${book} ${chapter}`);
const data = await res.json();
const data = await fetchStudyChapter('BSB', book, chapter);
const verses = parseBibleChapter(data);
const bookName = bookOptions.find((b) => b.abbrev === book)?.name ?? book;
const chunk = {
@@ -337,7 +396,7 @@ const AppInner = () => {
setCurrentPage('study');
} catch (err) {
console.error('studyReadingPlanDay error:', err);
toast("Couldn't load today's passage. Please try again.", { tone: 'error' });
toast(err?.message?.startsWith("You're offline") ? err.message : "Couldn't load today's passage. Please try again.", { tone: 'error' });
} finally {
setStudyBuildBusy(false);
}
@@ -374,7 +433,8 @@ const AppInner = () => {
const retrySync = async () => {
if (!project) return;
const toSave = { ...project, lastEdited: Date.now() };
saveProjectToStorage(toSave);
try { saveProjectToStorage(toSave); } catch { /* storage full: still try the server */ }
unsyncedStudies.add(toSave.id);
await pushProject(toSave);
};
@@ -395,7 +455,7 @@ const AppInner = () => {
saveStatus, setSaveStatus,
syncStatus,
retrySync,
syncConflict, keepMyVersion, useServerVersion,
syncConflict, keepMyVersion, useServerVersion, mergeBothVersions,
// Home
projectIndex,
...library,