Harden sync and security; add IQ Bible, templates, password reset, backups
Build and Push Docker Image / build-and-push (push) Successful in 7m14s
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:
1 parent
6bb392933a
commit
f274bc9c8a
67 files changed
+4734
-1569
No files matched your search
+89
-29
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user