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

+79 -6
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react';
import { useToast } from '../components/Toasts.jsx';
import { clearSyncBase, getSyncBase, setSyncBase } from '../utils/syncBase.js';
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
import { knownOnServer, pendingDeletes as pendingDeleteIds, unsyncedStudies } from '../utils/syncQueue.js';
import {
saveProjectToStorage,
deleteProjectFromStorage,
@@ -29,6 +30,33 @@ export function useProjectLibrary({
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
// Studies saved here that the server hasn't accepted yet (saved offline, or the tab closed mid-sync).
// A study another device changed meanwhile is left alone; opening it offers the keep-mine/keep-theirs choice.
const pushPending = async () => {
for (const id of unsyncedStudies.list()) {
const local = loadProjectById(id);
if (!local) { unsyncedStudies.remove(id); continue; }
const result = await saveRemoteProject(local, { baseEdited: getSyncBase(id) });
if (result.ok) {
const serverEdited = result.data?.lastEdited ?? local.lastEdited;
setSyncBase(id, serverEdited);
unsyncedStudies.remove(id);
knownOnServer.add(id);
try { saveProjectToStorage({ ...local, lastEdited: serverEdited }); } catch { /* already saved */ }
} else if (result.status === 409) {
toast(`“${local.title}” changed on another device. Open it to choose which version to keep.`, { tone: 'error' });
}
}
setProjectIndex(loadProjectIndex());
};
const retryPendingDeletes = async () => {
for (const id of pendingDeleteIds.list()) {
if (pendingDeletes.current.has(id)) continue; // still inside its Undo window
const result = await deleteRemoteProject(id);
if (result.ok) { pendingDeleteIds.remove(id); knownOnServer.remove(id); }
}
};
// Once we know who's signed in, reconcile the local project index against the server.
// Runs on every authUser change (including logout -> different login) so a previous
// account's stale suggestions never linger after switching users. Projects that exist
@@ -43,15 +71,40 @@ export function useProjectLibrary({
const localIndex = loadProjectIndex();
listRemoteProjects().then(async (result) => {
if (!result.ok || cancelled) return;
// Deletes that never reached the server (offline, tab closed) go first, so those studies aren't pulled back.
// (the server's list above may still include them, so note which they are before they're cleared)
const gone = new Set(pendingDeleteIds.list());
await retryPendingDeletes();
const serverList = result.data.filter((e) => !gone.has(e.id));
const localMap = new Map(localIndex.map((e) => [e.id, e]));
// What this device has seen of each study on the server: a save made on top of anything older is
// checked against it, so a newer copy from another device is never overwritten silently.
for (const e of result.data) {
for (const e of serverList) {
const local = localMap.get(e.id);
setSyncBase(e.id, local ? Math.min(e.lastEdited ?? 0, local.lastEdited ?? 0) : e.lastEdited);
}
const missing = result.data.filter((e) => !localMap.has(e.id));
// A study this device knew the server had, that the server no longer has, was deleted somewhere else.
const onServer = new Set(serverList.map((e) => e.id));
const unsynced = new Set(unsyncedStudies.list());
const deletedElsewhere = localIndex.filter((e) => knownOnServer.has(e.id) && !onServer.has(e.id) && !unsynced.has(e.id) && !gone.has(e.id));
deletedElsewhere.forEach((e) => {
const copy = loadProjectById(e.id);
deleteProjectFromStorage(e.id);
clearSyncBase(e.id);
knownOnServer.remove(e.id);
localMap.delete(e.id);
toast(`“${e.title}” was deleted on another device`, {
action: {
label: 'Keep it',
onClick: () => { if (copy) { saveProjectToStorage(copy); unsyncedStudies.add(copy.id); setProjectIndex(loadProjectIndex()); pushPending(); } },
},
});
});
if (deletedElsewhere.length > 0) setProjectIndex(loadProjectIndex());
knownOnServer.replace(serverList.map((e) => e.id));
const missing = serverList.filter((e) => !localMap.has(e.id));
if (missing.length > 0) {
const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id)));
fetched.filter((r) => r.ok).forEach((r) => { saveProjectToStorage(r.data); setSyncBase(r.data.id, r.data.lastEdited); });
@@ -64,15 +117,23 @@ export function useProjectLibrary({
}
}
const stale = result.data.filter((e) => {
const stale = serverList.filter((e) => {
const local = localMap.get(e.id);
return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0);
return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0) && !unsynced.has(e.id);
});
if (!cancelled) setStaleLocalProjects(stale);
pushPending();
});
return () => { cancelled = true; };
}, [authUser]);
// Back online: send what the server missed.
useEffect(() => {
const online = () => { if (authUser) { retryPendingDeletes(); pushPending(); } };
window.addEventListener('online', online);
return () => window.removeEventListener('online', online);
}, [authUser]);
const resumeProject = (id) => {
const loaded = loadProjectById(id);
if (!loaded) return;
@@ -89,8 +150,14 @@ export function useProjectLibrary({
const updated = { ...loaded, title, lastEdited: Date.now() };
saveProjectToStorage(updated);
setProjectIndex(loadProjectIndex());
unsyncedStudies.add(id);
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
if (result.ok) setSyncBase(id, updated.lastEdited);
if (result.ok) {
const serverEdited = result.data?.lastEdited ?? updated.lastEdited;
setSyncBase(id, serverEdited);
unsyncedStudies.remove(id);
try { saveProjectToStorage({ ...updated, lastEdited: serverEdited }); setProjectIndex(loadProjectIndex()); } catch { /* already saved */ }
}
else if (result.status === 409) toast('That study changed on another device. Open it to choose which version to keep.', { tone: 'error' });
});
if (project?.id === id) {
@@ -104,7 +171,9 @@ export function useProjectLibrary({
if (!pendingDeletes.current.has(id)) return;
window.clearTimeout(pendingDeletes.current.get(id));
pendingDeletes.current.delete(id);
deleteRemoteProject(id); // fire-and-forget
deleteRemoteProject(id).then((result) => { // stays on the pending list until the server confirms
if (result.ok) { pendingDeleteIds.remove(id); knownOnServer.remove(id); }
});
};
useEffect(() => {
const flush = () => [...pendingDeletes.current.keys()].forEach(finishDelete);
@@ -117,6 +186,8 @@ export function useProjectLibrary({
if (!loaded) return;
deleteProjectFromStorage(id);
clearSyncBase(id);
unsyncedStudies.remove(id);
pendingDeleteIds.add(id);
setProjectIndex(loadProjectIndex());
pendingDeletes.current.set(id, window.setTimeout(() => finishDelete(id), 8000));
toast(`Deleted “${loaded.title}”`, {
@@ -125,6 +196,8 @@ export function useProjectLibrary({
onClick: () => {
window.clearTimeout(pendingDeletes.current.get(id));
pendingDeletes.current.delete(id);
pendingDeleteIds.remove(id);
unsyncedStudies.add(id); // re-sent in case the server copy was already removed
saveProjectToStorage(loaded);
setProjectIndex(loadProjectIndex());
},