Answer history and schedules - Every answer is recorded (deck, activity, topic, correct/wrong, time) in a versioned IndexedDB log (freezmo-learning v1, stores answers + srs), mirrored in memory for synchronous React reads and capped at 20k answers (oldest pruned). StudyFeed records as it grades. - Light SM-2 schedule per activity: 1 day, 3 days, then interval x ease; wrong answers return in ten minutes with the ease nudged down. Review, mistakes, stats - Library 'Smart review' card: due count, mistakes count (latest answer wrong — clears itself when answered correctly), today's goal progress. Review sessions freeze their item list at start. - New /review (due across decks, ?mistakes=1 global or ?deck= per deck) built on a shared SessionFeed with the study feed's snap/keyboard mechanics, wake lock and a results slide. - New /stats: day streak, five-week calendar, accuracy per subject and per deck, weakest topics (>=3 answers), daily-goal progress. - Daily goal configurable in Settings (10/20/30/50). Exam mode - New /exam: pick decks, question count, optional timer; no hints (a NoHints context hides hint rows and the self-check escape); results with score, per-deck and per-topic breakdown; wrong answers land in the mistakes set through the normal history. Reminders (conditional) - VAPID-gated: GET/POST/DELETE /api/push and POST /api/push/test with the standard route pattern, RATE_LIMIT_PUSH, zod, auth before body. Subscriptions stored server-side as endpoint + keys only (PUSH_DIR). UI hidden entirely without keys; iOS install note; service worker push/notificationclick handlers; scripts/send-reminders.mjs for a cron/systemd timer; web-push added as the one server dependency. Verified: new phase-2 suite 42/42 (history/SRS shapes from a real study run, mistakes clearing, review session, stats math, exam timer and breakdowns, VAPID-less hidden state plus the configured subscribe/validate/test/delete path against a second instance) plus regression 74/74, mobile 52/52, wizard 80/80, declutter 116/116, animation 8/8, native 35/35, phase1 38/38, i18n 25/25. Key parity 709/709. Lint + build clean. SW freezmo-v26.
334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Opt-in encrypted sync. The payload (decks, folders, homework — never the
|
|
* API key) is encrypted in this browser with AES-GCM using a key derived from
|
|
* the user's passphrase (PBKDF2-SHA256, 310k iterations). The server only
|
|
* ever stores the opaque envelope under a random sync ID.
|
|
*
|
|
* Conflict policy: server-stamped `updatedAt` is the clock; when both sides
|
|
* changed since the last sync the UI asks the user which one to keep.
|
|
*/
|
|
|
|
import { z } from "zod";
|
|
|
|
import { backupDataSchema } from "./backup";
|
|
import { ApiError } from "./client-api";
|
|
import { replaceFolders } from "./folders";
|
|
import { replaceHomework } from "./homework";
|
|
import { ensureLearningHydrated, learningStore, replaceLearning } from "./history";
|
|
import { localeHeader, t } from "./i18n";
|
|
import { authHeaders } from "./settings";
|
|
import { deckStore, replaceDecks } from "./storage";
|
|
import { homeworkStore } from "./homework";
|
|
import { folderStore } from "./folders";
|
|
import { clearDeviceKey, loadDeviceKey, saveDeviceKey } from "./sync-key";
|
|
import { newSyncId, setSyncRecord, syncStore, withoutDirtyTracking } from "./sync-state";
|
|
|
|
const KDF = "PBKDF2-SHA256";
|
|
const ITERATIONS = 310_000;
|
|
|
|
export type SyncEnvelope = {
|
|
v: 1;
|
|
kdf: "PBKDF2-SHA256";
|
|
iterations: number;
|
|
salt: string;
|
|
iv: string;
|
|
ciphertext: string;
|
|
};
|
|
|
|
export type SyncStatus = "pushed" | "pulled" | "up-to-date" | "conflict" | "locked" | "error" | "off";
|
|
export type SyncOutcome = { status: SyncStatus; message?: string };
|
|
|
|
const syncPayloadSchema = z.object({
|
|
app: z.literal("freezmo"),
|
|
kind: z.literal("sync"),
|
|
version: z.literal(1),
|
|
clientUpdatedAt: z.number(),
|
|
data: backupDataSchema,
|
|
});
|
|
|
|
type SyncPayload = z.infer<typeof syncPayloadSchema>;
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Encoding helpers (chunked: payloads can be megabytes) */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function toBase64(bytes: Uint8Array): string {
|
|
let binary = "";
|
|
const chunk = 0x8000;
|
|
for (let index = 0; index < bytes.length; index += chunk) {
|
|
binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
function fromBase64(value: string): Uint8Array {
|
|
const binary = atob(value);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
|
|
return bytes;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Crypto */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export async function deriveSyncKey(passphrase: string, saltBase64: string): Promise<CryptoKey> {
|
|
const material = await crypto.subtle.importKey(
|
|
"raw",
|
|
new TextEncoder().encode(passphrase),
|
|
"PBKDF2",
|
|
false,
|
|
["deriveKey"],
|
|
);
|
|
return crypto.subtle.deriveKey(
|
|
{ name: "PBKDF2", salt: fromBase64(saltBase64) as unknown as BufferSource, iterations: ITERATIONS, hash: "SHA-256" },
|
|
material,
|
|
{ name: "AES-GCM", length: 256 },
|
|
false,
|
|
["encrypt", "decrypt"],
|
|
);
|
|
}
|
|
|
|
async function encryptWithKey(key: CryptoKey, payload: unknown): Promise<{ iv: string; ciphertext: string }> {
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
const encoded = new TextEncoder().encode(JSON.stringify(payload));
|
|
const cipher = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded);
|
|
return { iv: toBase64(iv), ciphertext: toBase64(new Uint8Array(cipher)) };
|
|
}
|
|
|
|
async function decryptWithKey(key: CryptoKey, ivBase64: string, ciphertextBase64: string): Promise<unknown> {
|
|
const plain = await crypto.subtle.decrypt(
|
|
{ name: "AES-GCM", iv: fromBase64(ivBase64) as unknown as BufferSource },
|
|
key,
|
|
fromBase64(ciphertextBase64) as unknown as BufferSource,
|
|
);
|
|
return JSON.parse(new TextDecoder().decode(plain));
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Server API */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
async function apiGet(id: string): Promise<{ updatedAt: number; envelope: SyncEnvelope } | null> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(`/api/sync?id=${encodeURIComponent(id)}`, {
|
|
cache: "no-store",
|
|
headers: { ...authHeaders(), ...localeHeader() },
|
|
});
|
|
} catch {
|
|
throw new ApiError(t("settings.syncError"), "network");
|
|
}
|
|
if (response.status === 404) return null;
|
|
if (!response.ok) {
|
|
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
|
throw new ApiError(body?.error ?? t("settings.syncError"), "sync");
|
|
}
|
|
return (await response.json()) as { updatedAt: number; envelope: SyncEnvelope };
|
|
}
|
|
|
|
async function apiPut(id: string, envelope: SyncEnvelope): Promise<number> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch("/api/sync", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
|
|
body: JSON.stringify({ id, envelope }),
|
|
});
|
|
} catch {
|
|
throw new ApiError(t("settings.syncError"), "network");
|
|
}
|
|
if (!response.ok) {
|
|
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
|
throw new ApiError(body?.error ?? t("settings.syncError"), "sync");
|
|
}
|
|
const body = (await response.json()) as { updatedAt: number };
|
|
return body.updatedAt;
|
|
}
|
|
|
|
async function apiDelete(id: string): Promise<void> {
|
|
const response = await fetch(`/api/sync?id=${encodeURIComponent(id)}`, {
|
|
method: "DELETE",
|
|
headers: { ...authHeaders(), ...localeHeader() },
|
|
});
|
|
if (!response.ok) throw new ApiError(t("settings.syncError"), "sync");
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Payload */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
async function buildPayload(): Promise<SyncPayload> {
|
|
await ensureLearningHydrated();
|
|
return {
|
|
app: "freezmo",
|
|
kind: "sync",
|
|
version: 1,
|
|
clientUpdatedAt: Date.now(),
|
|
data: {
|
|
decks: deckStore.getSnapshot(),
|
|
folders: folderStore.getSnapshot(),
|
|
homework: homeworkStore.getSnapshot(),
|
|
history: learningStore.getAnswersSnapshot(),
|
|
srs: learningStore.getSrsSnapshot(),
|
|
},
|
|
};
|
|
}
|
|
|
|
async function applyRemote(key: CryptoKey, remote: { updatedAt: number; envelope: SyncEnvelope }): Promise<void> {
|
|
const parsed = syncPayloadSchema.parse(
|
|
await decryptWithKey(key, remote.envelope.iv, remote.envelope.ciphertext),
|
|
);
|
|
withoutDirtyTracking(() => {
|
|
replaceFolders(parsed.data.folders);
|
|
replaceDecks(parsed.data.decks);
|
|
replaceHomework(parsed.data.homework);
|
|
});
|
|
await replaceLearning(parsed.data.history ?? [], parsed.data.srs ?? []);
|
|
setSyncRecord({ lastSyncedAt: remote.updatedAt, dirtyAt: null, conflict: false });
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Public API */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export async function syncNow(strategy: "auto" | "push" | "pull" = "auto"): Promise<SyncOutcome> {
|
|
const record = syncStore.getSnapshot();
|
|
if (!record.enabled || !record.id) return { status: "off" };
|
|
const key = await loadDeviceKey();
|
|
if (!key) return { status: "locked" };
|
|
|
|
try {
|
|
const remote = await apiGet(record.id);
|
|
const remoteIsNewer = Boolean(remote && remote.updatedAt > (record.lastSyncedAt ?? 0));
|
|
|
|
if (strategy === "pull") {
|
|
if (!remote) return { status: "error", message: t("server.syncNotFound") };
|
|
await applyRemote(key, remote);
|
|
return { status: "pulled" };
|
|
}
|
|
|
|
if (strategy === "auto" && remoteIsNewer) {
|
|
if (record.dirtyAt) {
|
|
setSyncRecord({ conflict: true });
|
|
return { status: "conflict" };
|
|
}
|
|
await applyRemote(key, remote!);
|
|
return { status: "pulled" };
|
|
}
|
|
|
|
if (strategy === "auto" && !remoteIsNewer && !record.dirtyAt) {
|
|
return { status: "up-to-date" };
|
|
}
|
|
|
|
const { iv, ciphertext } = await encryptWithKey(key, await buildPayload());
|
|
const envelope: SyncEnvelope = {
|
|
v: 1,
|
|
kdf: KDF,
|
|
iterations: ITERATIONS,
|
|
salt: record.salt,
|
|
iv,
|
|
ciphertext,
|
|
};
|
|
const updatedAt = await apiPut(record.id, envelope);
|
|
setSyncRecord({ lastSyncedAt: updatedAt, dirtyAt: null, conflict: false });
|
|
return { status: "pushed" };
|
|
} catch (error) {
|
|
return {
|
|
status: "error",
|
|
message: error instanceof ApiError ? error.message : t("settings.syncError"),
|
|
};
|
|
}
|
|
}
|
|
|
|
/** First device: brand-new id and salt, uploads straight away. */
|
|
export async function enableSync(passphrase: string): Promise<SyncOutcome> {
|
|
const id = newSyncId();
|
|
const salt = toBase64(crypto.getRandomValues(new Uint8Array(16)));
|
|
const key = await deriveSyncKey(passphrase, salt);
|
|
await saveDeviceKey(key);
|
|
setSyncRecord({ enabled: true, id, salt, lastSyncedAt: null, dirtyAt: Date.now(), conflict: false });
|
|
return syncNow("push");
|
|
}
|
|
|
|
/** Same device after the browser dropped the stored key. */
|
|
export async function unlockSync(passphrase: string): Promise<SyncOutcome> {
|
|
const record = syncStore.getSnapshot();
|
|
if (!record.enabled || !record.id) return { status: "off" };
|
|
try {
|
|
const remote = await apiGet(record.id);
|
|
const salt = remote?.envelope.salt || record.salt;
|
|
if (!salt) return { status: "error", message: t("settings.syncError") };
|
|
const key = await deriveSyncKey(passphrase, salt);
|
|
if (remote) {
|
|
try {
|
|
syncPayloadSchema.parse(await decryptWithKey(key, remote.envelope.iv, remote.envelope.ciphertext));
|
|
} catch {
|
|
return { status: "error", message: t("settings.syncWrongPass") };
|
|
}
|
|
}
|
|
await saveDeviceKey(key);
|
|
setSyncRecord({ salt });
|
|
return syncNow("auto");
|
|
} catch (error) {
|
|
return {
|
|
status: "error",
|
|
message: error instanceof ApiError ? error.message : t("settings.syncError"),
|
|
};
|
|
}
|
|
}
|
|
|
|
/** New device: the sync ID plus the passphrase from the other device. */
|
|
export async function joinSync(id: string, passphrase: string): Promise<SyncOutcome> {
|
|
try {
|
|
const remote = await apiGet(id.trim());
|
|
if (!remote) return { status: "error", message: t("server.syncNotFound") };
|
|
const key = await deriveSyncKey(passphrase, remote.envelope.salt);
|
|
try {
|
|
syncPayloadSchema.parse(await decryptWithKey(key, remote.envelope.iv, remote.envelope.ciphertext));
|
|
} catch {
|
|
return { status: "error", message: t("settings.syncWrongPass") };
|
|
}
|
|
await saveDeviceKey(key);
|
|
setSyncRecord({
|
|
enabled: true,
|
|
id: id.trim(),
|
|
salt: remote.envelope.salt,
|
|
lastSyncedAt: null,
|
|
dirtyAt: null,
|
|
conflict: false,
|
|
});
|
|
await applyRemote(key, remote);
|
|
return { status: "pulled" };
|
|
} catch (error) {
|
|
return {
|
|
status: "error",
|
|
message: error instanceof ApiError ? error.message : t("settings.syncError"),
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function deleteSyncedData(): Promise<SyncOutcome> {
|
|
const record = syncStore.getSnapshot();
|
|
try {
|
|
if (record.id) await apiDelete(record.id);
|
|
} catch (error) {
|
|
return {
|
|
status: "error",
|
|
message: error instanceof ApiError ? error.message : t("settings.syncError"),
|
|
};
|
|
}
|
|
await clearDeviceKey();
|
|
setSyncRecord({ enabled: false, id: "", salt: "", lastSyncedAt: null, dirtyAt: null, conflict: false });
|
|
return { status: "up-to-date" };
|
|
}
|
|
|
|
export async function forgetDeviceKey(): Promise<void> {
|
|
await clearDeviceKey();
|
|
}
|
|
|
|
export async function hasDeviceKey(): Promise<boolean> {
|
|
return Boolean(await loadDeviceKey());
|
|
}
|