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
@@ -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());
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user