diff --git a/.env.example b/.env.example index cea1779..4821c26 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,17 @@ TEXT_MODEL=gpt-oss:120b-cloud # RATE_LIMIT_GRADE=40 # RATE_LIMIT_HOMEWORK=12 # RATE_LIMIT_HEALTH=90 +# Encrypted sync (no model calls, but shouldn't be hammered). +# RATE_LIMIT_SYNC=60 + +# ENCRYPTED SYNC STORAGE ------------------------------------------------------- +# Opt-in sync uploads an opaque AES-GCM blob; the server can't read it and +# never sees the passphrase or the API key. One small JSON file per sync ID. +# SYNC_DIR=.freezmo/sync +# Per-blob cap in bytes (larger uploads are refused with HTTP 413). +# SYNC_MAX_BYTES=12582912 +# Total cap for the directory in bytes (new blobs refused with HTTP 507). +# SYNC_TOTAL_BYTES=1073741824 # Optional tuning ------------------------------------------------------------ # OLLAMA_TIMEOUT_MS=300000 diff --git a/.freezmo/sync/5ae112cd4eb9482371fca2ca5763b0a1.json b/.freezmo/sync/5ae112cd4eb9482371fca2ca5763b0a1.json new file mode 100644 index 0000000..b6f4d34 --- /dev/null +++ b/.freezmo/sync/5ae112cd4eb9482371fca2ca5763b0a1.json @@ -0,0 +1 @@ +{"updatedAt":1791639132272,"envelope":{"v":1,"kdf":"PBKDF2-SHA256","iterations":310000,"salt":"3gJY2tj3dYMr1D0oQizqwA==","iv":"RFCBpcjnG2cXQLUp","ciphertext":"yJsD95QqkCAo9qOW0fDfyGpW6hQpxyuZgrQ5hYOlN7zUTYImHovu1MHPChAvTNrUtx50LurDy2NpZqmPbon0UR9OPuMcd8VVEzm0kkUiWf7Izzm5NHiHoNFR5DVDJ87ZRRxjeXf2S4v9p9xdBlGBcnT88RMOR/IzqY8WD43AcLWsmz89R071L9zspajHYYfMpuisu0kUAt1et+GPC8JzKduyGTT9TiV1s8mzoE/BzgdQmY/ru+U0GXus5Oes0Bwf/xe/xGSEuF+HNk3GcAU3n89lLnp/ky5M7U0eRzNH4LFqERDNqDfszkLZ4hPLsbwG2+XkdtWssQ6UIwAVCqCjt1EMSxKwZo2xGNrOA2ICWjvK2CJswNRIpcPx83pN65ebDa+L8Rpd+R2NzXjhLk13VEBdjbUZhJ/yN1M0NBQrH+6idIiKFfkU+LL86JGowxcQ1kO7lsMWeL44CHNy+ydadAINlbjUWihrJnw7cQlfm/PK3T0m0QgRtl8Ket2RA4uloLzSgoXV8WupkvNH7PinLBggaMLWCNfJ3vdGeJ22vD+/j7gdeQV9l1q10GmaAJShcMPStsxKxQjrQ3SpmFJz04wT/7isf7A5Mo/MrsSM1xnHo2R4x6L0em66TbenLMJSUDWLYLyykkp/dKvJwOhJT9OXbHC0RrfpQGlKxFzlf2uG4MCc+oKIHe8XeVrl5lC+7tr5Hk+7dYqo82pEk7QApGXqSZHM2BVIaMyxENkOZkDkYopeo07OziO030ApnmszLh2dxh8EoK6D6S04V/al1IoatUerWWXZzW2YsF04vNBYXxGjBdyQYoyElfqQDoiApVGXe1FS2dhGEr1aOagPIFGRnXQDt7t3eMb/05zOCkNaoZVZ+qb5ItjhLTg6UpBIj95ceV5TVIt3p89hHIBTih5DQ4RN0M4izUt4tSf1YKSn/mTcE8E0VroBSQ9vUsMWhWJmhnmt15z+JPYMbcmDgFpuS+gMg3jIPZ/JOJqdK9mV0inFSKsZS7KFRP5TGiKK9VhTqmT6yKABJSC2X5fS488xYqLywo1i3ZjKUyitPgjyiFKu3P+maqLudBRSY5NygPg5HreuV5fV52S+jYiGM9u2oXs/waxRnCi0UreHxDKxY1/DYu5FvYR5t/+9egEhLieAHA4k4HW+RUJ9Z96A6bMdwqfQShWLZNhpXNnzBt2eAJHvWENRBD7x5mptv7Zd3u/GBinofJo38XreHotOUoHmJrXYenIvORf+tnKyw9C1FjJqDfiliMEjZnsAz86Oo19cfaC4WFiFa6xwuZym9ID3TiDtCO3Gn+WoYGWFGT6iLSyVHfliSDWyTJrjuKdkAxP712zpXKPteXM2/YuL0Qh6HDDhO6pPTpGNGMkRRg0yyP73BYcqhe4u5BnPg0fowE6Giox6Ja+/hzVmDSA4pKSwu8wcdM9C4ZVFU0xzE9lHg8qEcoHKOpfuP2ZieWm3K5MC9KdyVaIwvmARzFBE2I/OlRgM/Isdi1ZPw8Bt3qANwlj6JatjT5IM//1pZQ5wWYIZ0YLgNTqriC/wmD+eUerYmfHKz31Um9hC1SLRhd/g42i2ja8l0iHpGL646lLzCE+PchLcG1MN+oR8YUeFYXM="}} \ No newline at end of file diff --git a/.freezmo/sync/9efde47835835cbf42c442e426c8f803.json b/.freezmo/sync/9efde47835835cbf42c442e426c8f803.json new file mode 100644 index 0000000..c7363e2 --- /dev/null +++ b/.freezmo/sync/9efde47835835cbf42c442e426c8f803.json @@ -0,0 +1 @@ +{"updatedAt":1791639207633,"envelope":{"v":1,"kdf":"PBKDF2-SHA256","iterations":310000,"salt":"xDjHj3YhND34am6jbfS97w==","iv":"RzAl0juMEGme0Mht","ciphertext":"c/G8SDMbp2bka8R7BKP3zyxlxXQin1N5Z5rn78Pxec0XZ33t6O2q/hIGB/MP1a5TuKQB7QkZQ1wNBqJMdU58UhR3dQe71SgHV5Als844Gs11TKxcVvQCdyRt/LlQeWc6ry/PygAWsZH1kjuhNp/0IqYgXMIJHPYhjJ2fZI3ywiEEDQA7bD32naGmp3GHMa61qI4CevUi5iyUxdNtKoywelZO9lPWbpC+G8ZLunZvaiQxXNNlrQjBxrY81uKz3PULb87WKi07SYauXbZCF0k8xw+/XGSorrDCUGmJlueJ0tt7FTuEuYKzK/lg7AIhSy3EAA3VBgVO4SPw6oVyfUfhDS8C1bBZPNvRu0xiU+zQV4cqsazesCRr3HOjoEIxjI+hVabYvD4ltw2qDWhoJVXVjtokfm+9wer/F93C3L8j4iiq7LkDoUwSZSvbmQgrE3CCdNCugW+YokbRATJx33lgvpZBKiIaX6d/MOoGe9FEvWG8Ya80PF/mfx713SAktdvEIb4Bf7qkQ/eaUmn8WhOdcuINvi5vtgBerfq4JBRz/mWsKSNQZUooGVBSgUXKGjeDFhG0O3b8LjrhTS1lUD8nL2i7Ox0fZgiIHNTQI3N/KkA63re+calLT5VMai7LkoU4r1bDdb1XjEcWnVQosvgSFbNWgrUdY/9/ZXHvUkNdFHyEVfhij52/rJEk2SexhiEQrer6oq7T5W1JSCD5tqcpT96K9bD6TKMmIp/Fl9nHIxniLArtA3wmrUF0jI4lt4cY4iwe8k8qJI3t52cRDd3n1royj8JMRXnq5CLteqnhhdOUpry4MKOmxlNcDFB+DM0urMVDeMOfYrMCS4/1Znb4wgOe70LRoiCO3I05Lg57B+717Cz6/zHPJA0G7BqrP0lhqbJVSKp6B5ARx62BNkgjVHq9rAT3l9qIcIKqOXUs/a1awlx+e3Y+3U022i2sYVcCi4Te9AiPvBt909mxUqA/OUSnXZW+DSyAzNmD0HD2fKDroSFgOcjjZez+fWm5eIq5"}} \ No newline at end of file diff --git a/README.md b/README.md index 6af0567..2a23873 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,16 @@ no database, no data leaving your machine except the requests you send to Ollama study sessions and homework guides; wizard drafts survive a reload or an OS kill; re-tapping the active tab scrolls to the top; an offline pill appears and generation is disabled with a reason until the connection is back. +- **Backup, sync and limits**: *Settings → Data & backup* exports **everything** (decks, folders, + homework, progress, safe settings — never the API key) to one JSON file and imports a backup with + a preview and a merge/replace choice; a gentle, dismissible nudge appears after 5 new decks or 14 + days without a backup, and the storage-persistence state (`navigator.storage.persist()`) is shown. + *Settings → Sync* is **off by default** and opt-in: the data is encrypted in the browser + (PBKDF2-SHA256 → AES-GCM, key kept as a non-extractable `CryptoKey`) before it is uploaded, the + server stores only an opaque blob under a random 128-bit sync ID, and conflicts are surfaced with + an explicit *keep this device / use synced copy* choice. When Ollama's **free monthly usage** runs + out, every flow that calls the model shows the same clear message with **Open Settings** (jumps to + the key field) and **Open Ollama** — never a silent fallback to another model. - **Photos and documents**: JPG/PNG/WEBP/HEIC images plus **PDF, DOCX, PPTX, ODT/ODP/ODS, TXT, MD, CSV** - Documents are read **in the browser**: PDF text layers are extracted, scanned PDF pages are rendered as images for the vision model, DOCX/PPTX text, speaker notes and embedded images are pulled out. @@ -403,6 +413,7 @@ client IP with a sliding window. Everything is configurable in `.env.local`: | `POST /api/homework` | 12 per 5 minutes | `RATE_LIMIT_HOMEWORK` | | `POST /api/grade` | 40 per 5 minutes | `RATE_LIMIT_GRADE` | | `GET /api/health` | 90 per 5 minutes | `RATE_LIMIT_HEALTH` | +| `POST/GET/DELETE /api/sync` | 60 per 5 minutes | `RATE_LIMIT_SYNC` | | window for all of them | 5 minutes | `RATE_LIMIT_WINDOW_MS` | When a limit is hit the route answers **429** with a `Retry-After` header and a friendly message @@ -699,6 +710,9 @@ scripts/ | `POST /api/homework` | `{ mode: "make" \| "guide", images?: string[], text?, language? }` | NDJSON stream: `{type:"stage"}`, `{type:"homework",homework,meta}`, `{type:"error"}` | | `POST /api/grade` | `{ question, referenceAnswer, keyPoints, userAnswer, language? }` | `{ correct, score, feedback, missing }` | | `GET /api/health` | – | `{ ok, reachable, mode, baseUrl, hasApiKey, vision, text, latencyMs, modelCount, error }` | +| `POST /api/sync` | `{ id, envelope }` (opaque AES-GCM blob) | `{ updatedAt }` — 413 over `SYNC_MAX_BYTES`, 507 over `SYNC_TOTAL_BYTES` | +| `GET /api/sync?id=` | – | `{ updatedAt, envelope }` or 404 | +| `DELETE /api/sync?id=` | – | `{ ok: true }` (revokes the synced copy) | All four routes accept an optional **`x-ollama-key`** header (the browser's own key; required in `user-key` auth mode). Missing keys answer **401** with `kind: "needs_key"`, a rejected key answers @@ -745,8 +759,13 @@ grading, regeneration and “add more” (and the assignment language for homewo `freezmo.settings.v1`) and sent with each API call so the server can forward it to Ollama. It is never written to the server, a log, a cookie or the environment — see [Ollama setup](#ollama-setup) for the full story. -- Clearing browser data deletes the library and the saved key. Use **Export JSON** for anything you - want to keep. +- Clearing browser data deletes the library and the saved key. Use **Export all data** + (Settings → Data & backup) for anything you want to keep; the file never contains the API key. +- **Opt-in sync** (`freezmo.sync.v1` + a non-extractable `CryptoKey` in IndexedDB) uploads an + AES-GCM envelope to `POST /api/sync`. The server writes one small JSON file per sync ID under + `SYNC_DIR` (default `.freezmo/sync`), can't decrypt it, and never receives the passphrase or the + key. Deleting the synced copy is one button in Settings (and works even on a device that can't + unlock the data). --- @@ -758,6 +777,7 @@ grading, regeneration and “add more” (and the assignment language for homewo | A document card is stuck on “Reading page …” | The pdf.js worker may be missing: run `npm run dev`/`npm run build` (they copy it to `public/pdf.worker.min.mjs`) or `node scripts/copy-pdf-worker.mjs` manually. | | PDF uploaded but no activities about its content | The PDF is probably image-only (a scan). Freezmo renders such pages and sends them to the vision model — check the card note (“N scanned pages were turned into images”); if it says “No readable text”, the scan may be too low quality. | | Deck came back in English even though the notes are not | Make sure both model calls succeed (a failed vision step falls back to raw text). The prompts force the notes' language; if you still see translation, switch `TEXT_MODEL` to a stronger instruction-follower such as `gpt-oss:120b-cloud`. | +| “You've run out of your free monthly Ollama usage” | The account's free monthly usage is used up — this is not a short-term rate limit. Upgrade the account at `ollama.com/settings`, or create a new account and paste its key in Settings (the button jumps straight to the field). “Ollama is busy” is the short-term variant: just press Retry. | | “Add your Ollama API key first” | Bring-your-own-key mode is active. Paste a key from on the key gate (or in the ⚙️ dialog) — it is stored only in your browser. | | “Ollama rejected this API key” | The key is invalid or revoked. Create a new one and paste it again; the check uses a real 1-token request, so a green tick means it truly works. | | “this model is not included in your free usage” | The key works, but the model is outside your plan. Use one of the ✅ models in the table above, or add credits at . | @@ -1001,6 +1021,21 @@ Things I chose while building, with the reasoning: manifest side). Pull-to-refresh and the app badge were skipped on purpose — the Settings refresh icon already re-checks the connection, and there is no meaningful badge number. +40. **Backups are plain, sync is encrypted, and the key never leaves either.** The backup file is + the same zod-validated shape the sync payload uses (`lib/backup.ts`), which is why the import + preview can count added/replaced items without touching the key — the key is simply not part of + the shape. Sync adds PBKDF2-SHA256 (310k iterations) → AES-GCM on top, keeps the derived key as + a **non-extractable** `CryptoKey` in IndexedDB, and lets the server be a dumb blob store: it + stamps `updatedAt` (the conflict clock), enforces `SYNC_MAX_BYTES` / `SYNC_TOTAL_BYTES` and + writes atomically (temp file + rename). A device that lost its stored key can still *unlock* + (passphrase re-derivation) or *revoke* (DELETE) the synced copy. Monthly-usage exhaustion and + short-term throttling are told apart by inspecting the real 429 body + (`classifyOllamaFailure`) and mapped to `usage_limit` / `throttled` kinds — the client renders + the same localized message in every model flow, with a jump to the key field and a link to + Ollama, and keeps the user's input so Retry works right after swapping the key. Verified by the + phase-1 suite (38 checks, including the real route mapping against a stub Ollama returning a + monthly-limit body, and a two-device sync round-trip). + 14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`). Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 1f33716..a013da8 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -98,6 +98,14 @@ export async function GET(request: Request) { health.error = translate(locale, "server.healthNoCredits", { model: config.textModel }); health.kind = "no_credits"; } + if (probe.reason === "usage_limit") { + health.error = translate(locale, "server.usageLimit"); + health.kind = "usage_limit"; + } + if (probe.reason === "throttled") { + health.error = translate(locale, "server.ollamaThrottled"); + health.kind = "throttled"; + } } const names = new Set(models.map((model) => canonicalModelName(model.name))); diff --git a/app/api/sync/route.ts b/app/api/sync/route.ts new file mode 100644 index 0000000..fc1f1a3 --- /dev/null +++ b/app/api/sync/route.ts @@ -0,0 +1,196 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { z } from "zod"; + +import { authErrorKind, authErrorMessage, getServerConfig, resolveAuth } from "@/lib/config"; +import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; +import { translate } from "@/lib/i18n-dict"; +import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Opt-in encrypted sync storage. The server only ever sees: + * - a random sync ID (the capability) + * - an opaque AES-GCM envelope (salt, iv, ciphertext) and a server timestamp + * It cannot read the data and never receives the passphrase or API key. + */ + +const idSchema = z.string().regex(/^[A-Za-z0-9_-]{16,64}$/); + +const envelopeSchema = z.object({ + v: z.literal(1), + kdf: z.literal("PBKDF2-SHA256"), + iterations: z.number().int().min(50_000).max(2_000_000), + salt: z.string().min(8).max(128), + iv: z.string().min(8).max(64), + ciphertext: z.string().min(1).max(24_000_000), +}); + +const postSchema = z.object({ id: idSchema, envelope: envelopeSchema }); + +function fileFor(dir: string, id: string): string { + return path.join(dir, `${id}.json`); +} + +async function writeAtomic(file: string, data: string): Promise { + await fs.mkdir(path.dirname(file), { recursive: true }); + const temporary = `${file}.${crypto.randomUUID()}.tmp`; + await fs.writeFile(temporary, data, "utf8"); + await fs.rename(temporary, file); +} + +/** Size of all stored blobs except `exclude` (the one being replaced). */ +async function directoryBytes(dir: string, exclude: string): Promise { + let total = 0; + try { + for (const entry of await fs.readdir(dir)) { + if (!entry.endsWith(".json")) continue; + const full = path.join(dir, entry); + if (full === exclude) continue; + try { + total += (await fs.stat(full)).size; + } catch { + /* deleted meanwhile */ + } + } + } catch { + /* directory doesn't exist yet */ + } + return total; +} + +export async function GET(request: Request) { + const locale = requestLocale(request); + const crossOrigin = rejectCrossOrigin(request); + if (crossOrigin) return crossOrigin; + + const config = getServerConfig(); + const limit = rateLimit(`sync:${clientKey(request)}`, config.rateLimits.sync, config.rateLimits.windowMs); + if (!limit.allowed) return rateLimitResponse(limit, locale); + + const auth = resolveAuth(config, request); + if ("error" in auth) { + return Response.json( + { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, + { status: 401 }, + ); + } + + const id = new URL(request.url).searchParams.get("id") ?? ""; + if (!idSchema.safeParse(id).success) { + return Response.json( + { error: translate(locale, "server.syncInvalidId") }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ); + } + + try { + const raw = await fs.readFile(fileFor(config.sync.dir, id), "utf8"); + return new Response(raw, { + status: 200, + headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, + }); + } catch { + return Response.json( + { error: translate(locale, "server.syncNotFound") }, + { status: 404, headers: { "Cache-Control": "no-store" } }, + ); + } +} + +export async function POST(request: Request) { + const locale = requestLocale(request); + const crossOrigin = rejectCrossOrigin(request); + if (crossOrigin) return crossOrigin; + + const config = getServerConfig(); + const oversized = rejectOversizedBody(request, config.sync.maxBytes); + if (oversized) return oversized; + + const limit = rateLimit(`sync:${clientKey(request)}`, config.rateLimits.sync, config.rateLimits.windowMs); + if (!limit.allowed) return rateLimitResponse(limit, locale); + + // Auth before parsing the body, like every other route. + const auth = resolveAuth(config, request); + if ("error" in auth) { + return Response.json( + { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, + { status: 401 }, + ); + } + + let payload: unknown; + try { + payload = await request.json(); + } catch { + return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 }); + } + + const parsed = postSchema.safeParse(payload); + if (!parsed.success) { + return Response.json( + { + error: translate(locale, "server.invalidRequest", { + detail: parsed.error.issues + .slice(0, 3) + .map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`) + .join("; "), + }), + }, + { status: 400 }, + ); + } + + const file = fileFor(config.sync.dir, parsed.data.id); + const serialised = JSON.stringify({ updatedAt: Date.now(), envelope: parsed.data.envelope }); + + // Total storage cap, checked only when a new blob is created. + const exists = await fs + .stat(file) + .then(() => true) + .catch(() => false); + if (!exists) { + const used = await directoryBytes(config.sync.dir, file); + if (used + serialised.length > config.sync.totalBytes) { + return Response.json( + { error: translate(locale, "server.syncStorageFull"), kind: "storage_full" }, + { status: 507 }, + ); + } + } + + await writeAtomic(file, serialised); + const updatedAt = (JSON.parse(serialised) as { updatedAt: number }).updatedAt; + return Response.json({ updatedAt }, { headers: { "Cache-Control": "no-store" } }); +} + +export async function DELETE(request: Request) { + const locale = requestLocale(request); + const crossOrigin = rejectCrossOrigin(request); + if (crossOrigin) return crossOrigin; + + const config = getServerConfig(); + const limit = rateLimit(`sync:${clientKey(request)}`, config.rateLimits.sync, config.rateLimits.windowMs); + if (!limit.allowed) return rateLimitResponse(limit, locale); + + const auth = resolveAuth(config, request); + if ("error" in auth) { + return Response.json( + { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, + { status: 401 }, + ); + } + + const id = new URL(request.url).searchParams.get("id") ?? ""; + if (!idSchema.safeParse(id).success) { + return Response.json( + { error: translate(locale, "server.syncInvalidId") }, + { status: 400, headers: { "Cache-Control": "no-store" } }, + ); + } + + await fs.unlink(fileFor(config.sync.dir, id)).catch(() => {}); + return Response.json({ ok: true }, { headers: { "Cache-Control": "no-store" } }); +} diff --git a/components/AppHeader.tsx b/components/AppHeader.tsx index 9c251db..a5fea35 100644 --- a/components/AppHeader.tsx +++ b/components/AppHeader.tsx @@ -19,9 +19,14 @@ import { cn } from "@/lib/utils"; export function AppHeader({ className, compact = false }: { className?: string; compact?: boolean }) { const { t } = useI18n(); const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsFocus, setSettingsFocus] = useState<{ field: "key"; nonce: number } | null>(null); useEffect(() => { - const onOpen = () => setSettingsOpen(true); + const onOpen = (event: Event) => { + setSettingsOpen(true); + const focus = (event as CustomEvent<{ focus?: string }>).detail?.focus; + if (focus === "key") setSettingsFocus({ field: "key", nonce: Date.now() }); + }; window.addEventListener(OPEN_SETTINGS_EVENT, onOpen); return () => window.removeEventListener(OPEN_SETTINGS_EVENT, onOpen); }, []); @@ -78,7 +83,11 @@ export function AppHeader({ className, compact = false }: { className?: string; - setSettingsOpen(false)} /> + setSettingsOpen(false)} + focusField={settingsFocus} + /> ); } diff --git a/components/DataSection.tsx b/components/DataSection.tsx new file mode 100644 index 0000000..235ded2 --- /dev/null +++ b/components/DataSection.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/ui"; +import { + applyImport, + downloadBackup, + getStoragePersistence, + parseBackup, + previewImport, + requestStoragePersistence, + type BackupFile, + type ImportPreview, + type PersistenceState, +} from "@/lib/backup"; +import { useI18n } from "@/lib/i18n"; + +/** + * Settings → Data & backup: export everything to one file, import a backup + * with a preview and a merge/replace choice, and show whether the browser + * treats this origin's storage as persistent. + */ +export function DataSection() { + const { t } = useI18n(); + const fileRef = useRef(null); + const [persistence, setPersistence] = useState(null); + const [pending, setPending] = useState<{ backup: BackupFile; preview: ImportPreview } | null>(null); + const [notice, setNotice] = useState(null); + + useEffect(() => { + void getStoragePersistence().then(setPersistence); + }, []); + + const pick = async (file: File | undefined) => { + setNotice(null); + if (!file) return; + const raw = await file.text().catch(() => ""); + const result = parseBackup(raw); + if ("error" in result) { + setPending(null); + setNotice(result.error); + return; + } + setPending({ backup: result.backup, preview: previewImport(result.backup) }); + }; + + const reset = () => { + setPending(null); + if (fileRef.current) fileRef.current.value = ""; + }; + + const apply = (mode: "merge" | "replace") => { + if (!pending) return; + const done = applyImport(pending.backup, mode); + setNotice(t("settings.importDone", { decks: done.decks, homework: done.homework })); + reset(); + }; + + const requestPersistent = async () => { + const granted = await requestStoragePersistence(); + setPersistence(granted ? "persistent" : "best-effort"); + setNotice(granted ? t("settings.storageGranted") : t("settings.storageDenied")); + }; + + return ( +
+

{t("settings.data")}

+

{t("settings.dataNote")}

+ +
+ + + void pick(event.target.files?.[0])} + /> +
+

{t("settings.exportNote")}

+ + {pending ? ( +
+

+ {t("settings.importPreview", { + decks: pending.preview.decks.added + pending.preview.decks.replaced, + homework: pending.preview.homework.added + pending.preview.homework.replaced, + folders: pending.preview.folders.added + pending.preview.folders.replaced, + })} +

+

{t("settings.importMergeNote")}

+

{t("settings.importReplaceNote")}

+
+ + + +
+
+ ) : null} + + {notice ?

{notice}

: null} + +
+ + {t("settings.storage")} + + {persistence === "persistent" + ? t("settings.storagePersistent") + : persistence === "unsupported" + ? t("settings.storageBestEffort") + : t("settings.storageBestEffort")} + + + {persistence === "best-effort" ? ( + + ) : null} +
+
+ ); +} diff --git a/components/LibraryScreen.tsx b/components/LibraryScreen.tsx index 872040e..b201315 100644 --- a/components/LibraryScreen.tsx +++ b/components/LibraryScreen.tsx @@ -2,7 +2,7 @@ import { AnimatePresence, motion } from "framer-motion"; import Link from "next/link"; -import { useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { DeckCard } from "@/components/DeckCard"; import { FolderBar } from "@/components/FolderBar"; @@ -17,6 +17,7 @@ import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav"; import { springGentle } from "@/lib/motion"; import { UNORGANIZED_ID, type Deck } from "@/lib/schemas"; import { deckStore, deleteFolder, importDeck, sortDecks, upsertDeck } from "@/lib/storage"; +import { backupNudgeDue, downloadBackup, snoozeBackupNudge } from "@/lib/backup"; import { useIsPhone } from "@/lib/use-phone"; import { cn } from "@/lib/utils"; @@ -46,6 +47,11 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null); const [editingFolder, setEditingFolder] = useState<{ id: string; name: string } | null>(null); const [confirmDelete, setConfirmDelete] = useState(null); + const [nudgeDue, setNudgeDue] = useState(false); + + useEffect(() => { + setNudgeDue(backupNudgeDue(decks.length)); + }, [decks.length]); const importRef = useRef(null); const sorted = useMemo(() => sortDecks(decks), [decks]); @@ -227,6 +233,37 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | exit={isPhone ? { opacity: 0, y: -6, transition: { duration: 0.16 } } : undefined} transition={springGentle} > + {/* gentle backup nudge — dismissible, never blocks ---------------- */} + {nudgeDue ? ( +
+
+

{t("settings.backupNudgeTitle")}

+

{t("settings.backupNudgeBody")}

+
+
+ + +
+
+ ) : null} + {/* decks: folders + card grid, or a decks-only empty state --------- */} {tab === "decks" && isEmpty ? ( + + + + ); +} diff --git a/components/SettingsDialog.tsx b/components/SettingsDialog.tsx index 518c94f..3b60882 100644 --- a/components/SettingsDialog.tsx +++ b/components/SettingsDialog.tsx @@ -5,7 +5,11 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { LOCALES, useI18n, type Locale } from "@/lib/i18n"; import { haptic } from "@/lib/haptics"; +import { modelErrorCopy } from "@/lib/model-errors"; import { springSheet } from "@/lib/motion"; +import { ModelErrorActions } from "@/components/ModelErrorActions"; +import { DataSection } from "@/components/DataSection"; +import { SyncSection } from "@/components/SyncSection"; import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings"; import { useBackClose } from "@/lib/use-back-close"; import { cn } from "@/lib/utils"; @@ -35,10 +39,20 @@ function freshnessLabel(checkedAt: number, locale: Locale, t: (key: string, vars return t("health.checkedAt", { time }); } -export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) { +export function SettingsDialog({ + open, + onClose, + focusField, +}: { + open: boolean; + onClose: () => void; + /** Set by openSettings("key") — focuses the key input once the sheet opens. */ + focusField?: { field: "key"; nonce: number } | null; +}) { const { health, loading, error, refresh, apiKey, checkedAt } = useHealth(); const installAvailable = useInstallAvailable(); const { locale, setLocale, t, plural } = useI18n(); + const keyInputRef = useRef(null); const settings = useSyncExternalStore( settingsStore.subscribe, settingsStore.getSnapshot, @@ -46,6 +60,16 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () = ); /* The system Back gesture closes the sheet instead of leaving the page. */ useBackClose(open, onClose); + + /* "Open Settings" from a usage-limit error lands right on the key field. */ + useEffect(() => { + if (!open || focusField?.field !== "key") return; + const timer = window.setTimeout(() => { + keyInputRef.current?.scrollIntoView({ block: "center", behavior: "smooth" }); + keyInputRef.current?.focus({ preventScroll: true }); + }, 380); + return () => window.clearTimeout(timer); + }, [open, focusField]); const [keyDraft, setKeyDraft] = useState(apiKey); const [reveal, setReveal] = useState(false); const [saved, setSaved] = useState(false); @@ -265,12 +289,24 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () = {statusText} + {/* monthly usage used up / short-term throttle */} + {health?.kind === "usage_limit" || health?.kind === "throttled" ? ( +
+

{modelErrorCopy(health.kind)?.title}

+

{modelErrorCopy(health.kind)?.body}

+
+ +
+
+ ) : null} +
setKeyDraft(event.target.value)} @@ -356,6 +392,10 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
+ {/* ------------------------------------------- data & sync */} + + + {/* ----------------------------------------------- install */} {installAvailable ? (
@@ -566,6 +606,7 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () = setKeyDraft(event.target.value)} @@ -666,6 +707,10 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () = ) : null}
+ {/* ------------------------------------------- data & sync */} + + + {/* ------------------------------------------------ install */} {installAvailable ? (
@@ -772,6 +817,11 @@ function HealthPanel({ health }: { health: ReturnType["health" ? t("health.modelMissing") : t("health.unreachable")} {health.error ?

{health.error}

: null} + {health.kind === "usage_limit" || health.kind === "throttled" ? ( + + + + ) : null} diff --git a/components/ShareChooser.tsx b/components/ShareChooser.tsx index a67c389..411ae54 100644 --- a/components/ShareChooser.tsx +++ b/components/ShareChooser.tsx @@ -66,7 +66,7 @@ export function ShareChooser({

{t(error === "large" ? "share.tooLarge" : "share.expired")}

-
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 8158f5c..790a5da 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -296,7 +296,7 @@ function NavContent({
+ + + ) : ( + + )} +
+ ); + + return ( +
+

{t("settings.sync")}

+

{t("settings.syncNote")}

+

{t("settings.syncNoteKey")}

+ + {!record.enabled ? ( + mode === "enable" ? ( +
+ setPass(event.target.value)} + placeholder={t("settings.syncPassphrase")} + autoComplete="new-password" + spellCheck={false} + className={fieldClass} + /> + setPass2(event.target.value)} + placeholder={t("settings.syncPassphraseConfirm")} + autoComplete="new-password" + spellCheck={false} + className={fieldClass} + /> + {error ?

{error}

: null} +
+ + +
+
+ ) : mode === "join" ? ( +
+ setJoinId(event.target.value)} + placeholder={t("settings.syncJoinId")} + autoComplete="off" + spellCheck={false} + className={fieldClass} + /> + setPass(event.target.value)} + placeholder={t("settings.syncPassphrase")} + autoComplete="off" + spellCheck={false} + className={fieldClass} + /> +

{t("settings.syncJoinNote")}

+ {error ?

{error}

: null} +
+ + +
+
+ ) : ( +
+ + +
+ ) + ) : locked === false ? ( +
+

{t("settings.syncLocked")}

+ setPass(event.target.value)} + placeholder={t("settings.syncPassphrase")} + autoComplete="off" + spellCheck={false} + className={fieldClass} + /> + {error ?

{error}

: null} + + {deleteBlock} +
+ ) : ( +
+
+ + + {t("settings.syncId")} + + {record.id} + + +
+ +

{lastSynced}

+ + {record.conflict ? ( +
+

{t("settings.syncConflict")}

+
+ + +
+
+ ) : ( + + )} + + {notice ?

{notice}

: null} + {error ?

{error}

: null} + +
+ + {deleteBlock} +
+
+ )} +
+ ); +} diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx index 0d7662d..7f28a76 100644 --- a/components/ThemeToggle.tsx +++ b/components/ThemeToggle.tsx @@ -31,6 +31,16 @@ export function syncThemeColor(theme: "light" | "dark") { } } +export function applyTheme(next: "light" | "dark") { + document.documentElement.classList.toggle("dark", next === "dark"); + syncThemeColor(next); + try { + window.localStorage.setItem(STORAGE_KEY, next); + } catch { + /* private mode */ + } +} + export function useTheme() { const [theme, setTheme] = useState<"light" | "dark">("light"); @@ -45,13 +55,7 @@ export function useTheme() { const apply = (next: "light" | "dark") => { setTheme(next); - document.documentElement.classList.toggle("dark", next === "dark"); - syncThemeColor(next); - try { - window.localStorage.setItem(STORAGE_KEY, next); - } catch { - /* private mode */ - } + applyTheme(next); }; return { theme, toggle: () => apply(theme === "dark" ? "light" : "dark") }; diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx index 1e67656..7aa2add 100644 --- a/components/UploadScreen.tsx +++ b/components/UploadScreen.tsx @@ -35,6 +35,8 @@ import { takePendingShare } from "@/lib/share-pending"; import { upsertDeck } from "@/lib/storage"; import { showToast } from "@/lib/toast"; import { openSettings } from "@/lib/ui-events"; +import { modelErrorCopy } from "@/lib/model-errors"; +import { ModelErrorActions } from "@/components/ModelErrorActions"; import { useBackClose } from "@/lib/use-back-close"; import { useIsPhone } from "@/lib/use-phone"; import { useOnline } from "@/lib/use-online"; @@ -536,22 +538,28 @@ export function UploadScreen() { className="animate-rise rounded-3xl border-2 border-rose-300 bg-rose-50/95 p-4 text-sm text-rose-900 dark:border-rose-400/40 dark:bg-rose-400/10 dark:text-rose-100" >

- {error.kind === "needs_key" - ? t("upload.errNeedsKey") - : error.kind === "rate_limited" - ? t("upload.errRate") - : error.kind === "auth" - ? t("upload.errAuth") - : t("upload.errFailed")} + {modelErrorCopy(error.kind)?.title ?? + (error.kind === "needs_key" + ? t("upload.errNeedsKey") + : error.kind === "rate_limited" + ? t("upload.errRate") + : error.kind === "auth" + ? t("upload.errAuth") + : t("upload.errFailed"))}

-

{error.message}

+

{modelErrorCopy(error.kind)?.body ?? error.message}

{error.kind === "needs_key" || error.kind === "auth" ? ( - ) : null} -
diff --git a/components/activities/ShortAnswer.tsx b/components/activities/ShortAnswer.tsx index 8615908..e62f9c8 100644 --- a/components/activities/ShortAnswer.tsx +++ b/components/activities/ShortAnswer.tsx @@ -4,9 +4,11 @@ import { useEffect, useRef, useState } from "react"; import { ApiError, gradeAnswer } from "@/lib/client-api"; import { useI18n } from "@/lib/i18n"; +import { modelErrorCopy } from "@/lib/model-errors"; import type { Activity } from "@/lib/schemas"; import { cn } from "@/lib/utils"; import { Button, Spinner } from "@/components/ui"; +import { ModelErrorActions } from "@/components/ModelErrorActions"; import { ActivityHeader, @@ -23,7 +25,7 @@ type GradeState = | { status: "idle" } | { status: "grading" } | { status: "graded"; correct: boolean; feedback: string; missing: string[] } - | { status: "error"; message: string } + | { status: "error"; message: string; kind?: string } | { status: "self-check" }; export function ShortAnswer({ activity, active, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) { @@ -64,6 +66,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is error instanceof ApiError ? error.message : t("short.gradeFailed"), + kind: error instanceof ApiError ? error.kind : undefined, }); } }; @@ -119,12 +122,13 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is {state.status === "error" ? (
-

{t("short.couldNotGrade")}

-

{state.message}

+

{modelErrorCopy(state.kind)?.title ?? t("short.couldNotGrade")}

+

{modelErrorCopy(state.kind)?.body ?? state.message}

+ diff --git a/components/homework/HomeworkInput.tsx b/components/homework/HomeworkInput.tsx index 97d5e04..db52f9a 100644 --- a/components/homework/HomeworkInput.tsx +++ b/components/homework/HomeworkInput.tsx @@ -16,6 +16,7 @@ import type { HomeworkMode, HomeworkRecord } from "@/lib/schemas"; import { ACCEPTED_TYPES, formatBytes, imageFilesFromDataTransfer } from "@/lib/image"; import { useI18n } from "@/lib/i18n"; import { stepVariants } from "@/lib/motion"; +import { modelErrorCopy } from "@/lib/model-errors"; import { takePendingShare } from "@/lib/share-pending"; import { useBackClose } from "@/lib/use-back-close"; import type { useNotesIntake } from "@/lib/use-notes-intake"; @@ -23,6 +24,7 @@ import { useIsPhone } from "@/lib/use-phone"; import { useOnline } from "@/lib/use-online"; import { clearWizardDraft, loadWizardDraft, saveWizardDraft } from "@/lib/wizard-draft"; import { openSettings } from "@/lib/ui-events"; +import { ModelErrorActions } from "@/components/ModelErrorActions"; import { cn, truncate } from "@/lib/utils"; import { HomeworkHistory } from "./HomeworkHistory"; @@ -150,6 +152,7 @@ export function HomeworkInput({ const hasContent = intake.images.length > 0 || documentImages.length > 0 || text.trim().length > 0 || documentText.trim().length > 0; const canSubmit = hasContent && !intake.busy && !documentsBusy; + const modelCopy = modelErrorCopy(error?.kind); const keyError = error?.kind === "needs_key" || error?.kind === "auth"; const errorCard = error ? ( @@ -157,14 +160,15 @@ export function HomeworkInput({ role="alert" className="animate-rise rounded-3xl border-2 border-rose-300 bg-rose-50/95 p-4 text-sm text-rose-900 dark:border-rose-400/40 dark:bg-rose-400/10 dark:text-rose-100" > -

{keyError ? t("upload.errAuth") : t("homework.error.title")}

-

{error.message || t("homework.error.body")}

+

{modelCopy?.title ?? (keyError ? t("upload.errAuth") : t("homework.error.title"))}

+

{modelCopy?.body ?? (error.message || t("homework.error.body"))}

{keyError ? ( - ) : null} + diff --git a/lib/backup.ts b/lib/backup.ts new file mode 100644 index 0000000..80152d0 --- /dev/null +++ b/lib/backup.ts @@ -0,0 +1,251 @@ +"use client"; + +/** + * Full-data backup: one JSON file with decks, folders, homework, progress and + * safe settings — **never the API key**. Import validates with zod, shows a + * preview of what will be added or replaced, and offers merge or replace. + * + * The same payload shape is what encrypted sync uploads (minus settings), so + * the two features share this file's validation. + */ + +import { z } from "zod"; + +import { applyTheme } from "@/components/ThemeToggle"; +import { replaceFolders, folderStore } from "./folders"; +import { homeworkStore, replaceHomework } from "./homework"; +import { setLocale, t } from "./i18n"; +import { folderSchema, deckSchema, homeworkRecordSchema } from "./schemas"; +import { saveSettings, settingsStore } from "./settings"; +import { deckStore, replaceDecks } from "./storage"; + +export const BACKUP_VERSION = 1; + +const settingsSchema = z.object({ + haptics: z.boolean().optional(), + locale: z.enum(["en", "cs"]).optional(), + theme: z.enum(["light", "dark"]).optional(), +}); + +export const backupDataSchema = z.object({ + decks: z.array(deckSchema).max(2000), + folders: z.array(folderSchema).max(500), + homework: z.array(homeworkRecordSchema).max(2000), +}); + +export const backupSchema = z.object({ + app: z.literal("freezmo"), + kind: z.literal("backup"), + version: z.literal(1), + exportedAt: z.string(), + data: backupDataSchema.extend({ settings: settingsSchema.optional() }), +}); + +export type BackupFile = z.infer; + +/* ------------------------------------------------------------------ */ +/* Building and downloading */ +/* ------------------------------------------------------------------ */ + +export function buildBackup(): BackupFile { + return { + app: "freezmo", + kind: "backup", + version: BACKUP_VERSION, + exportedAt: new Date().toISOString(), + data: { + decks: deckStore.getSnapshot(), + folders: folderStore.getSnapshot(), + homework: homeworkStore.getSnapshot(), + settings: { + haptics: settingsStore.getSnapshot().haptics, + locale: (window.localStorage.getItem("freezmo.locale.v1") as "en" | "cs" | null) ?? undefined, + theme: (window.localStorage.getItem("freezmo.theme") as "light" | "dark" | null) ?? undefined, + }, + }, + }; +} + +export function downloadBackup(): BackupFile { + const backup = buildBackup(); + const blob = new Blob([JSON.stringify(backup, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `freezmo-backup-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + markBackedUp(backup.data.decks.length); + return backup; +} + +/* ------------------------------------------------------------------ */ +/* Parsing, previewing, applying */ +/* ------------------------------------------------------------------ */ + +export function parseBackup(raw: string): { backup: BackupFile } | { error: string } { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { error: t("import.notJson") }; + } + const result = backupSchema.safeParse(parsed); + if (!result.success) return { error: t("import.notBackup") }; + return { backup: result.data }; +} + +export type ImportCounts = { added: number; replaced: number }; + +function countMerge(current: T[], incoming: T[]): ImportCounts { + const ids = new Set(current.map((item) => item.id)); + let added = 0; + let replaced = 0; + for (const item of incoming) { + if (ids.has(item.id)) replaced += 1; + else added += 1; + } + return { added, replaced }; +} + +function mergeById(current: T[], incoming: T[]): T[] { + const positions = new Map(current.map((item, index) => [item.id, index])); + const merged = [...current]; + for (const item of incoming) { + const at = positions.get(item.id); + if (at === undefined) { + positions.set(item.id, merged.length); + merged.push(item); + } else { + merged[at] = item; + } + } + return merged; +} + +export type ImportPreview = { + decks: ImportCounts; + folders: ImportCounts; + homework: ImportCounts; +}; + +export function previewImport(backup: BackupFile): ImportPreview { + return { + decks: countMerge(deckStore.getSnapshot(), backup.data.decks), + folders: countMerge(folderStore.getSnapshot(), backup.data.folders), + homework: countMerge(homeworkStore.getSnapshot(), backup.data.homework), + }; +} + +/** Applies a backup; the API key is never touched (it is not in the file). */ +export function applyImport(backup: BackupFile, mode: "merge" | "replace"): { decks: number; homework: number } { + const data = backup.data; + const decks = + mode === "replace" ? data.decks : mergeById(deckStore.getSnapshot(), data.decks); + const folders = + mode === "replace" ? data.folders : mergeById(folderStore.getSnapshot(), data.folders); + const homework = + mode === "replace" ? data.homework : mergeById(homeworkStore.getSnapshot(), data.homework); + + // Folders first so imported decks can point at them. Importing is a real + // local change, so it also marks sync dirty (via the stores' write()). + replaceFolders(folders); + replaceDecks(decks); + replaceHomework(homework); + + const settings = data.settings; + if (settings) { + if (typeof settings.haptics === "boolean") saveSettings({ haptics: settings.haptics }); + if (settings.locale) setLocale(settings.locale); + if (settings.theme) applyTheme(settings.theme); + } + + return { decks: decks.length, homework: homework.length }; +} + +/* ------------------------------------------------------------------ */ +/* Backup nudge */ +/* ------------------------------------------------------------------ */ + +const BACKUP_STATE_KEY = "freezmo.backup.v1"; +const NUDGE_DAYS = 14; +const NUDGE_DECKS = 5; + +type BackupState = { + lastBackupAt: number | null; + decksAtLastBackup: number; + snoozedUntil: number | null; +}; + +const EMPTY_STATE: BackupState = { lastBackupAt: null, decksAtLastBackup: 0, snoozedUntil: null }; + +function readBackupState(): BackupState { + try { + const raw = window.localStorage.getItem(BACKUP_STATE_KEY); + if (!raw) return EMPTY_STATE; + const parsed = JSON.parse(raw) as Partial; + return { + lastBackupAt: typeof parsed.lastBackupAt === "number" ? parsed.lastBackupAt : null, + decksAtLastBackup: typeof parsed.decksAtLastBackup === "number" ? parsed.decksAtLastBackup : 0, + snoozedUntil: typeof parsed.snoozedUntil === "number" ? parsed.snoozedUntil : null, + }; + } catch { + return EMPTY_STATE; + } +} + +function writeBackupState(next: BackupState): void { + try { + window.localStorage.setItem(BACKUP_STATE_KEY, JSON.stringify(next)); + } catch { + /* private mode */ + } +} + +export function markBackedUp(deckCount: number): void { + writeBackupState({ lastBackupAt: Date.now(), decksAtLastBackup: deckCount, snoozedUntil: null }); +} + +export function snoozeBackupNudge(): void { + const state = readBackupState(); + writeBackupState({ ...state, snoozedUntil: Date.now() + NUDGE_DAYS * 86_400_000 }); +} + +export function backupNudgeDue(deckCount: number): boolean { + if (deckCount < 2) return false; + const state = readBackupState(); + if (state.snoozedUntil && state.snoozedUntil > Date.now()) return false; + if (state.lastBackupAt === null) return deckCount >= NUDGE_DECKS; + if (Date.now() - state.lastBackupAt > NUDGE_DAYS * 86_400_000) return true; + return deckCount >= state.decksAtLastBackup + NUDGE_DECKS; +} + +export function lastBackupAt(): number | null { + return readBackupState().lastBackupAt; +} + +/* ------------------------------------------------------------------ */ +/* Storage persistence */ +/* ------------------------------------------------------------------ */ + +export type PersistenceState = "persistent" | "best-effort" | "unsupported"; + +export async function getStoragePersistence(): Promise { + try { + if (!navigator.storage?.persisted) return "unsupported"; + return (await navigator.storage.persisted()) ? "persistent" : "best-effort"; + } catch { + return "unsupported"; + } +} + +export async function requestStoragePersistence(): Promise { + try { + if (!navigator.storage?.persist) return false; + return await navigator.storage.persist(); + } catch { + return false; + } +} diff --git a/lib/config.ts b/lib/config.ts index bd00e40..0551405 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -7,6 +7,7 @@ import { sanitiseApiKey } from "./http-guard"; import { translate, type Locale } from "./i18n-dict"; +import path from "node:path"; export type AuthMode = "user-key" | "server" | "local"; @@ -29,6 +30,15 @@ export type ServerConfig = { grade: number; homework: number; health: number; + sync: number; + }; + sync: { + /** Where opaque encrypted sync blobs live (SYNC_DIR). */ + dir: string; + /** Per-blob cap in bytes (SYNC_MAX_BYTES). */ + maxBytes: number; + /** Total cap for the whole directory in bytes (SYNC_TOTAL_BYTES). */ + totalBytes: number; }; }; @@ -99,6 +109,13 @@ export function getServerConfig(): ServerConfig { // Homework is one to two model calls, like a deck. homework: positiveInt(process.env.RATE_LIMIT_HOMEWORK, 12), health: positiveInt(process.env.RATE_LIMIT_HEALTH, 90), + // Sync is cheap (no model calls) but shouldn't be hammered. + sync: positiveInt(process.env.RATE_LIMIT_SYNC, 60), + }, + sync: { + dir: process.env.SYNC_DIR?.trim() || path.join(process.cwd(), ".freezmo", "sync"), + maxBytes: positiveInt(process.env.SYNC_MAX_BYTES, 12 * 1024 * 1024), + totalBytes: positiveInt(process.env.SYNC_TOTAL_BYTES, 1024 * 1024 * 1024), }, }; } diff --git a/lib/folders.ts b/lib/folders.ts index 686f85e..277d69f 100644 --- a/lib/folders.ts +++ b/lib/folders.ts @@ -10,6 +10,7 @@ import { t } from "./i18n"; import { folderSchema, UNORGANIZED_ID, type Folder } from "./schemas"; +import { markSyncDirty } from "./sync-state"; import { uid } from "./utils"; const STORAGE_KEY = "freezmo.folders.v1"; @@ -55,9 +56,21 @@ function write(next: Folder[]): void { } catch (error) { console.warn("[freezmo] could not save folders", error); } + markSyncDirty(); listeners.forEach((listener) => listener()); } +/** Used by backup import and sync: wholesale replacement of the folders. */ +export function replaceFolders(next: Folder[]): void { + const valid: Folder[] = []; + for (const entry of next) { + const result = folderSchema.safeParse(entry); + if (result.success) valid.push(result.data); + else console.warn("[freezmo] skipping invalid folder in import", result.error.issues); + } + write(valid); +} + export const folderStore = { subscribe(listener: Listener): () => void { ensureLoaded(); diff --git a/lib/homework.ts b/lib/homework.ts index 9085c89..a1d2dc8 100644 --- a/lib/homework.ts +++ b/lib/homework.ts @@ -16,6 +16,7 @@ import { type HomeworkRecord, type HomeworkStep, } from "./schemas"; +import { markSyncDirty } from "./sync-state"; import { uid } from "./utils"; const STORAGE_KEY = "freezmo.homework.v1"; @@ -63,9 +64,21 @@ function write(next: HomeworkRecord[]): void { } catch (error) { console.warn("[freezmo] could not save the homework history (storage full?)", error); } + markSyncDirty(); listeners.forEach((listener) => listener()); } +/** Used by backup import and sync: wholesale replacement of the history. */ +export function replaceHomework(next: HomeworkRecord[]): void { + const valid: HomeworkRecord[] = []; + for (const entry of next) { + const result = homeworkRecordSchema.safeParse(entry); + if (result.success) valid.push(result.data as HomeworkRecord); + else console.warn("[freezmo] skipping invalid homework record in import", result.error.issues); + } + write(valid); +} + export const homeworkStore = { subscribe(listener: Listener): () => void { ensureLoaded(); diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts index 58693a7..93f0666 100644 --- a/lib/i18n-dict.ts +++ b/lib/i18n-dict.ts @@ -151,6 +151,70 @@ const en: Record = { "settings.haptics": "Haptics", "settings.hapticsNote": "Vibration feedback on supported phones.", + /* data, backup & sync --------------------------------------------- */ + "settings.data": "Data & backup", + "settings.dataNote": "Everything lives in this browser. Export a backup regularly.", + "settings.exportAll": "Export all data", + "settings.exportNote": "Decks, homework, progress and settings — never your API key.", + "settings.importBackup": "Import backup", + "settings.importPreview": "This backup has {decks} decks, {homework} homework results and {folders} folders.", + "settings.importMergeNote": "Merge keeps what's here and adds the backup; matching IDs are replaced.", + "settings.importReplaceNote": "Replace wipes this browser's data and uses the backup instead.", + "settings.importMerge": "Merge", + "settings.importReplace": "Replace", + "settings.importCancel": "Cancel", + "settings.importDone": "Imported {decks} decks and {homework} homework results.", + "settings.importFailed": "That backup could not be imported.", + "settings.storage": "Storage", + "settings.storagePersistent": "Persistent — the browser won't evict your data.", + "settings.storageBestEffort": "Best-effort — the browser may clear data under pressure.", + "settings.storageRequest": "Ask for persistence", + "settings.storageGranted": "Granted — your data is now persistent.", + "settings.storageDenied": "The browser declined. Installing the app usually helps.", + "settings.backupNudgeTitle": "Back up your decks?", + "settings.backupNudgeBody": "You've added decks since your last backup. A backup keeps them safe.", + "settings.backupNow": "Back up now", + "settings.backupLater": "Not now", + "settings.sync": "Sync", + "settings.syncNote": + "Off by default. Data is encrypted in this browser before upload — the server stores an opaque blob and never sees your data or key.", + "settings.syncEnable": "Turn on sync", + "settings.syncPassphrase": "Sync passphrase", + "settings.syncPassphraseConfirm": "Repeat the passphrase", + "settings.syncPassphraseMismatch": "The passphrases don't match.", + "settings.syncPassphraseShort": "Use at least 8 characters.", + "settings.syncStart": "Start syncing", + "settings.syncCancel": "Cancel", + "settings.syncId": "Sync ID", + "settings.syncCopy": "Copy", + "settings.syncCopied": "Copied.", + "settings.syncLast": "Last synced {when}", + "settings.syncNever": "Not synced yet.", + "settings.syncNow": "Sync now", + "settings.syncWorking": "Syncing…", + "settings.syncDone": "Up to date.", + "settings.syncPushed": "Uploaded from this device.", + "settings.syncPulled": "Downloaded from the synced copy.", + "settings.syncConflict": "This device and the synced copy both changed. Choose which one to keep.", + "settings.syncKeepLocal": "Keep this device", + "settings.syncKeepRemote": "Use synced copy", + "settings.syncJoin": "Join another sync", + "settings.syncJoinNote": + "Enter the sync ID and passphrase from your other device. Data in this browser is replaced by the synced copy.", + "settings.syncJoinId": "Sync ID", + "settings.syncJoinAction": "Join", + "settings.syncLocked": "Enter your sync passphrase to unlock syncing on this device.", + "settings.syncUnlock": "Unlock", + "settings.syncForget": "Forget passphrase on this device", + "settings.syncDelete": "Delete synced data", + "settings.syncDeleteConfirm": "Delete the synced copy from the server? This device keeps its data.", + "settings.syncDeleteYes": "Delete", + "settings.syncWrongPass": "That passphrase can't unlock the synced copy.", + "settings.syncError": "Sync failed. Check your connection and try again.", + "settings.syncOff": "Sync is off.", + "settings.syncOn": "Sync is on.", + "settings.syncNoteKey": "Your API key is never synced.", + /* offline / share / toasts ---------------------------------------- */ "offline.banner": "You're offline — saved decks still work.", "offline.generate": "You're offline — connect to generate.", @@ -693,6 +757,13 @@ const en: Record = { "server.healthModelNoteLocal": "Not in the daemon's model list. Run `ollama pull {model}` or check your sign-in.", "server.healthModelNoteCloud": "Not returned by {url}/api/tags. Check the model name and your key's plan.", "server.healthModelsMissing": "Ollama is reachable, but at least one configured model is not available.", + "server.usageLimit": + "Your free monthly Ollama usage has run out. Upgrade your Ollama account, or create a new one and use the new API key.", + "server.ollamaThrottled": "Ollama is temporarily rate limiting requests. Try again in a moment.", + "server.syncInvalidId": "That sync ID doesn't look right.", + "server.syncTooLarge": "This sync upload is too large.", + "server.syncStorageFull": "The sync storage is full. Delete some synced data and try again.", + "server.syncNotFound": "No synced data found for that ID.", /* folders / files -------------------------------------------------- */ "folder.nameRequired": "Give the folder a name.", @@ -704,6 +775,13 @@ const en: Record = { "file.documentTooLarge": "“{name}” is larger than 40 MB.", "import.notJson": "That file is not valid JSON.", "import.notDeck": "That file doesn't look like a Freezmo deck.", + "import.notBackup": "That file doesn't look like a Freezmo backup.", + "error.usageTitle": "Free monthly usage used up", + "error.usageBody": + "You've run out of your free monthly Ollama usage. Upgrade your Ollama account, or create a new one and use the new API key.", + "error.openOllama": "Open Ollama", + "error.throttledTitle": "Ollama is busy", + "error.throttledBody": "Ollama is temporarily rate limiting requests. Try again in a moment.", "doc.readingPage": "Reading page {n} of {total}…", "doc.readingWord": "Reading the Word document…", "doc.readingSlides": "Reading the slides…", @@ -801,6 +879,70 @@ const cs: Record = { "settings.haptics": "Haptika", "settings.hapticsNote": "Vibrace na podporovaných telefonech.", + /* data, backup & sync --------------------------------------------- */ + "settings.data": "Data a záloha", + "settings.dataNote": "Všechno je v tomto prohlížeči. Zálohuj pravidelně.", + "settings.exportAll": "Exportovat všechna data", + "settings.exportNote": "Sady, úkoly, pokrok a nastavení — nikdy tvůj API klíč.", + "settings.importBackup": "Importovat zálohu", + "settings.importPreview": "Záloha obsahuje {decks} sad, {homework} výsledků úkolů a {folders} složek.", + "settings.importMergeNote": "Sloučení ponechá stávající data a přidá zálohu; stejná ID se nahradí.", + "settings.importReplaceNote": "Nahrazení smaže data v tomto prohlížeči a použije zálohu.", + "settings.importMerge": "Sloučit", + "settings.importReplace": "Nahradit", + "settings.importCancel": "Zrušit", + "settings.importDone": "Importováno {decks} sad a {homework} výsledků úkolů.", + "settings.importFailed": "Zálohu se nepodařilo importovat.", + "settings.storage": "Úložiště", + "settings.storagePersistent": "Trvalé — prohlížeč tvá data nesmaže.", + "settings.storageBestEffort": "Dočasné — prohlížeč může data při nedostatku místa smazat.", + "settings.storageRequest": "Požádat o trvalé úložiště", + "settings.storageGranted": "Povoleno — data jsou teď trvalá.", + "settings.storageDenied": "Prohlížeč odmítl. Pomůže nainstalovat aplikaci.", + "settings.backupNudgeTitle": "Zálohovat sady?", + "settings.backupNudgeBody": "Od poslední zálohy jsi přidal sady. Záloha je udrží v bezpečí.", + "settings.backupNow": "Zálohovat teď", + "settings.backupLater": "Teď ne", + "settings.sync": "Synchronizace", + "settings.syncNote": + "Ve výchozím stavu vypnuto. Data se šifrují v tomto prohlížeči před odesláním — server ukládá jen neprůhledný blob a nikdy nevidí tvá data ani klíč.", + "settings.syncEnable": "Zapnout synchronizaci", + "settings.syncPassphrase": "Přístupová fráze", + "settings.syncPassphraseConfirm": "Zopakuj přístupovou frázi", + "settings.syncPassphraseMismatch": "Fráze se neshodují.", + "settings.syncPassphraseShort": "Použij aspoň 8 znaků.", + "settings.syncStart": "Začít synchronizovat", + "settings.syncCancel": "Zrušit", + "settings.syncId": "Sync ID", + "settings.syncCopy": "Kopírovat", + "settings.syncCopied": "Zkopírováno.", + "settings.syncLast": "Naposledy synchronizováno {when}", + "settings.syncNever": "Zatím nesynchronizováno.", + "settings.syncNow": "Synchronizovat teď", + "settings.syncWorking": "Synchronizuji…", + "settings.syncDone": "Vše je aktuální.", + "settings.syncPushed": "Nahráno z tohoto zařízení.", + "settings.syncPulled": "Staženo ze synchronizované kopie.", + "settings.syncConflict": "Toto zařízení i synchronizovaná kopie se změnily. Vyber, kterou ponechat.", + "settings.syncKeepLocal": "Ponechat toto zařízení", + "settings.syncKeepRemote": "Použít synchronizovanou kopii", + "settings.syncJoin": "Připojit k jiné synchronizaci", + "settings.syncJoinNote": + "Zadej sync ID a přístupovou frázi z druhého zařízení. Data v tomto prohlížeči nahradí synchronizovaná kopie.", + "settings.syncJoinId": "Sync ID", + "settings.syncJoinAction": "Připojit", + "settings.syncLocked": "Zadej přístupovou frázi a odemkni synchronizaci na tomto zařízení.", + "settings.syncUnlock": "Odemknout", + "settings.syncForget": "Zapomenout frázi na tomto zařízení", + "settings.syncDelete": "Smazat synchronizovaná data", + "settings.syncDeleteConfirm": "Smazat synchronizovanou kopii ze serveru? Data v tomto zařízení zůstanou.", + "settings.syncDeleteYes": "Smazat", + "settings.syncWrongPass": "Touhle frází se synchronizovaná kopie odemknout nedá.", + "settings.syncError": "Synchronizace selhala. Zkontroluj připojení a zkus to znovu.", + "settings.syncOff": "Synchronizace je vypnutá.", + "settings.syncOn": "Synchronizace je zapnutá.", + "settings.syncNoteKey": "Tvůj API klíč se nikdy nesynchronizuje.", + /* offline / share / toasts ---------------------------------------- */ "offline.banner": "Jsi offline — uložené sady fungují dál.", "offline.generate": "Jsi offline — pro vytvoření se připoj.", @@ -1343,6 +1485,13 @@ const cs: Record = { "server.healthModelNoteLocal": "Není v seznamu modelů daemonu. Spusť `ollama pull {model}` nebo zkontroluj přihlášení.", "server.healthModelNoteCloud": "Není v odpovědi {url}/api/tags. Zkontroluj název modelu a tarif klíče.", "server.healthModelsMissing": "Ollama je dostupná, ale aspoň jeden nakonfigurovaný model není k dispozici.", + "server.usageLimit": + "Vyčerpal se tvůj měsíční limit Ollama zdarma. Upgraduj svůj účet Ollama, nebo si vytvoř nový a použij jeho nový API klíč.", + "server.ollamaThrottled": "Ollama dočasně omezuje požadavky. Zkus to za chvíli znovu.", + "server.syncInvalidId": "Tenhle sync ID nevypadá správně.", + "server.syncTooLarge": "Tahle synchronizace je příliš velká.", + "server.syncStorageFull": "Úložiště synchronizace je plné. Smaž nějaká synchronizovaná data a zkus to znovu.", + "server.syncNotFound": "Pro tenhle ID nejsou žádná synchronizovaná data.", /* folders / files -------------------------------------------------- */ "folder.nameRequired": "Dej složce název.", @@ -1354,6 +1503,13 @@ const cs: Record = { "file.documentTooLarge": "„{name}“ je větší než 40 MB.", "import.notJson": "Tento soubor není platný JSON.", "import.notDeck": "Tento soubor nevypadá jako sada Freezmo.", + "import.notBackup": "Tento soubor nevypadá jako záloha Freezmo.", + "error.usageTitle": "Měsíční limit zdarma vyčerpán", + "error.usageBody": + "Vyčerpal se tvůj měsíční limit Ollama zdarma. Upgraduj svůj účet Ollama, nebo si vytvoř nový a použij jeho nový API klíč.", + "error.openOllama": "Otevřít Ollamu", + "error.throttledTitle": "Ollama je zaneprázdněná", + "error.throttledBody": "Ollama dočasně omezuje požadavky. Zkus to za chvíli znovu.", "doc.readingPage": "Čtu stranu {n} z {total}…", "doc.readingWord": "Čtu dokument Word…", "doc.readingSlides": "Čtu snímky…", diff --git a/lib/model-errors.ts b/lib/model-errors.ts new file mode 100644 index 0000000..2a804bf --- /dev/null +++ b/lib/model-errors.ts @@ -0,0 +1,24 @@ +"use client"; + +/** + * Copy for the two Ollama failure modes the user can actually do something + * about: the free monthly usage being used up (upgrade / new key) and + * short-term throttling (just retry). Every flow that calls the model uses + * this so the message is identical everywhere. + */ + +import { t } from "./i18n"; + +export const OLLAMA_SETTINGS_URL = "https://ollama.com/settings"; + +export type ModelErrorCopy = { title: string; body: string }; + +export function modelErrorCopy(kind: string | undefined): ModelErrorCopy | null { + if (kind === "usage_limit") { + return { title: t("error.usageTitle"), body: t("error.usageBody") }; + } + if (kind === "throttled") { + return { title: t("error.throttledTitle"), body: t("error.throttledBody") }; + } + return null; +} diff --git a/lib/ollama.ts b/lib/ollama.ts index 77bc3d0..35a2e19 100644 --- a/lib/ollama.ts +++ b/lib/ollama.ts @@ -24,10 +24,28 @@ export type OllamaErrorKind = | "timeout" | "auth" | "no_credits" + /** The free monthly Ollama usage is used up — only the user can fix this. */ + | "usage_limit" + /** Ollama is throttling short-term requests; retrying soon usually works. */ + | "throttled" | "model_missing" | "http" | "bad_response"; +/** + * Ollama reports two very different things with HTTP 429: + * - the free monthly usage is exhausted ("upgrade", "quota", "monthly …") + * - a short-term rate limit ("too many requests", "try again …") + * The body is the only signal, so classify it (kept pure for testing). + */ +const USAGE_LIMIT_PATTERN = + /(monthly|per month|this month|quota|usage limit|limit reached|free (plan|tier|usage)|upgrade|billing|subscribe|exceeded your)/i; + +export function classifyOllamaFailure(status: number, body: string): "usage_limit" | "throttled" | null { + if (status !== 429) return null; + return USAGE_LIMIT_PATTERN.test(body) ? "usage_limit" : "throttled"; +} + export class OllamaError extends Error { kind: OllamaErrorKind; status?: number; @@ -57,6 +75,10 @@ export function friendlyErrorMessage( return translate(locale, "server.ollamaTimeout"); case "auth": return translate(locale, "server.ollamaAuth"); + case "usage_limit": + return translate(locale, "server.usageLimit"); + case "throttled": + return translate(locale, "server.ollamaThrottled"); case "no_credits": return error.message; case "model_missing": @@ -166,6 +188,20 @@ export async function ollamaChat(options: ChatOptions): Promise { { status: response.status, detail: text.slice(0, 500) }, ); } + if (response.status === 429) { + if (classifyOllamaFailure(response.status, text) === "usage_limit") { + throw new OllamaError( + "usage_limit", + "Your free monthly Ollama usage has run out. Upgrade your Ollama account, or create a new one and use the new API key.", + { status: response.status, detail: text.slice(0, 500) }, + ); + } + throw new OllamaError( + "throttled", + "Ollama is temporarily rate limiting requests. Try again in a moment.", + { status: response.status, detail: text.slice(0, 500) }, + ); + } throw new OllamaError( "http", `Ollama responded with HTTP ${response.status}.`, @@ -256,7 +292,7 @@ export async function ollamaListModels(auth: OllamaAuth, timeoutMs?: number): Pr export type KeyProbe = { /** true = accepted, false = rejected, null = could not tell. */ valid: boolean | null; - reason?: "invalid" | "no_credits" | "model_missing" | "unreachable"; + reason?: "invalid" | "no_credits" | "usage_limit" | "throttled" | "model_missing" | "unreachable"; detail?: string; }; @@ -316,7 +352,14 @@ export async function ollamaVerifyKey(auth: OllamaAuth, model: string, timeoutMs else if (response.status === 401 || response.status === 403) probe = { valid: false, reason: "invalid", detail }; else if (response.status === 402) probe = { valid: true, reason: "no_credits", detail }; else if (response.status === 404) probe = { valid: null, reason: "model_missing", detail }; - else probe = { valid: null, detail: `HTTP ${response.status}` }; + else if (response.status === 429) { + // The key itself is fine; the account is either out of monthly usage or + // being throttled for a moment. + probe = + classifyOllamaFailure(response.status, detail) === "usage_limit" + ? { valid: true, reason: "usage_limit", detail } + : { valid: null, reason: "throttled", detail }; + } else probe = { valid: null, detail: `HTTP ${response.status}` }; probeCache.set(cacheKey, { ...probe, at: Date.now() }); return probe; diff --git a/lib/storage.ts b/lib/storage.ts index 36b6e4c..e2169c4 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -8,6 +8,7 @@ import { t } from "./i18n"; import { deckSchema, UNORGANIZED_ID, type Activity, type Deck } from "./schemas"; import { createFolder, findFolderByName, getFolder, removeFolder } from "./folders"; +import { markSyncDirty } from "./sync-state"; import { uid } from "./utils"; const STORAGE_KEY = "freezmo.decks.v1"; @@ -53,9 +54,21 @@ function write(next: Deck[]): void { } catch (error) { console.warn("[freezmo] could not save the deck library (storage full?)", error); } + markSyncDirty(); listeners.forEach((listener) => listener()); } +/** Used by backup import and sync: wholesale replacement of the library. */ +export function replaceDecks(next: Deck[]): void { + const valid: Deck[] = []; + for (const entry of next) { + const result = deckSchema.safeParse(entry); + if (result.success) valid.push(result.data); + else console.warn("[freezmo] skipping invalid deck in import", result.error.issues); + } + write(valid); +} + export const deckStore = { subscribe(listener: Listener): () => void { ensureLoaded(); diff --git a/lib/sync-key.ts b/lib/sync-key.ts new file mode 100644 index 0000000..a2cf5a4 --- /dev/null +++ b/lib/sync-key.ts @@ -0,0 +1,63 @@ +"use client"; + +/** + * The derived sync key is kept in IndexedDB as a **non-extractable** + * CryptoKey: it can encrypt/decrypt for this origin but its raw bytes can + * never be read out (not even by a script that got in). If the browser + * doesn't allow that, syncing asks for the passphrase again instead. + */ + +const DB_NAME = "freezmo-sync"; +const STORE = "keys"; +const KEY_ID = "device-key"; + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => { + request.result.createObjectStore(STORE); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +async function withStore(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise { + const db = await openDb(); + try { + return await new Promise((resolve, reject) => { + const transaction = db.transaction(STORE, mode); + const request = run(transaction.objectStore(STORE)); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } finally { + db.close(); + } +} + +export async function saveDeviceKey(key: CryptoKey): Promise { + try { + await withStore("readwrite", (store) => store.put(key, KEY_ID)); + return true; + } catch { + return false; + } +} + +export async function loadDeviceKey(): Promise { + try { + const key = await withStore("readonly", (store) => store.get(KEY_ID)); + return key ?? null; + } catch { + return null; + } +} + +export async function clearDeviceKey(): Promise { + try { + await withStore("readwrite", (store) => store.delete(KEY_ID)); + } catch { + /* ignore */ + } +} diff --git a/lib/sync-state.ts b/lib/sync-state.ts new file mode 100644 index 0000000..e69a385 --- /dev/null +++ b/lib/sync-state.ts @@ -0,0 +1,136 @@ +"use client"; + +/** + * Local bookkeeping for opt-in encrypted sync: whether it is on, the random + * sync ID, when this device last synced and whether local data changed since. + * The passphrase itself is never stored here — the derived key lives in + * IndexedDB as a non-extractable CryptoKey (lib/sync-key.ts) and nowhere else. + */ + +const STORAGE_KEY = "freezmo.sync.v1"; + +export type SyncRecord = { + v: 1; + enabled: boolean; + /** Random 128-bit id — the only handle the server ever sees. */ + id: string; + /** KDF salt (not secret; travels in the envelope too). */ + salt: string; + lastSyncedAt: number | null; + /** Set whenever local data changes; cleared after a successful sync. */ + dirtyAt: number | null; + /** True when a sync found both sides changed (the UI asks what to keep). */ + conflict: boolean; +}; + +const EMPTY: SyncRecord = { + v: 1, + enabled: false, + id: "", + salt: "", + lastSyncedAt: null, + dirtyAt: null, + conflict: false, +}; + +type Listener = () => void; + +let record: SyncRecord | null = null; +let cached: SyncRecord = EMPTY; +const listeners = new Set(); +let suppressDirty = false; + +function read(): SyncRecord { + if (typeof window === "undefined") return EMPTY; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return EMPTY; + const parsed = JSON.parse(raw) as Partial; + if (parsed.v !== 1) return EMPTY; + return { + v: 1, + enabled: parsed.enabled === true, + id: typeof parsed.id === "string" ? parsed.id : "", + salt: typeof parsed.salt === "string" ? parsed.salt : "", + lastSyncedAt: typeof parsed.lastSyncedAt === "number" ? parsed.lastSyncedAt : null, + dirtyAt: typeof parsed.dirtyAt === "number" ? parsed.dirtyAt : null, + conflict: parsed.conflict === true, + }; + } catch { + return EMPTY; + } +} + +function ensureLoaded(): void { + if (record !== null) return; + record = read(); + cached = record; +} + +function write(next: SyncRecord): void { + record = next; + cached = next; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + /* private mode */ + } + listeners.forEach((listener) => listener()); +} + +export const syncStore = { + subscribe(listener: Listener): () => void { + ensureLoaded(); + listeners.add(listener); + const onStorage = (event: StorageEvent) => { + if (event.key === STORAGE_KEY) { + record = null; + ensureLoaded(); + listeners.forEach((l) => l()); + } + }; + window.addEventListener("storage", onStorage); + return () => { + listeners.delete(listener); + window.removeEventListener("storage", onStorage); + }; + }, + getSnapshot(): SyncRecord { + ensureLoaded(); + return cached; + }, + getServerSnapshot(): SyncRecord { + return EMPTY; + }, +}; + +export function setSyncRecord(next: Partial): void { + ensureLoaded(); + write({ ...syncStore.getSnapshot(), ...next }); +} + +/** Called by every data store's write(); no-op when sync is off. */ +export function markSyncDirty(): void { + if (suppressDirty) return; + ensureLoaded(); + const current = syncStore.getSnapshot(); + if (!current.enabled || current.dirtyAt !== null) return; + write({ ...current, dirtyAt: Date.now() }); +} + +/** Applying a pulled payload must not mark the local data dirty again. */ +export function withoutDirtyTracking(fn: () => T): T { + suppressDirty = true; + try { + return fn(); + } finally { + suppressDirty = false; + } +} + +/** 16 random bytes as hex = 128 bits, unguessable. */ +export function newSyncId(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/lib/sync.ts b/lib/sync.ts new file mode 100644 index 0000000..895ee28 --- /dev/null +++ b/lib/sync.ts @@ -0,0 +1,329 @@ +"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 { 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; + +/* ------------------------------------------------------------------ */ +/* 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 { + 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 { + 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 { + 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 { + 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 */ +/* ------------------------------------------------------------------ */ + +function buildPayload(): SyncPayload { + return { + app: "freezmo", + kind: "sync", + version: 1, + clientUpdatedAt: Date.now(), + data: { + decks: deckStore.getSnapshot(), + folders: folderStore.getSnapshot(), + homework: homeworkStore.getSnapshot(), + }, + }; +} + +async function applyRemote(key: CryptoKey, remote: { updatedAt: number; envelope: SyncEnvelope }): Promise { + 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); + }); + setSyncRecord({ lastSyncedAt: remote.updatedAt, dirtyAt: null, conflict: false }); +} + +/* ------------------------------------------------------------------ */ +/* Public API */ +/* ------------------------------------------------------------------ */ + +export async function syncNow(strategy: "auto" | "push" | "pull" = "auto"): Promise { + 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, 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 { + 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 { + 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 { + 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 { + 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 { + await clearDeviceKey(); +} + +export async function hasDeviceKey(): Promise { + return Boolean(await loadDeviceKey()); +} diff --git a/lib/ui-events.ts b/lib/ui-events.ts index 84b2303..fca4bfd 100644 --- a/lib/ui-events.ts +++ b/lib/ui-events.ts @@ -7,7 +7,10 @@ export const OPEN_SETTINGS_EVENT = "freezmo:open-settings"; -export function openSettings(): void { +export type SettingsFocus = "key"; + +/** `focus` scrolls the settings dialog to that field once it opens. */ +export function openSettings(focus?: SettingsFocus): void { if (typeof window === "undefined") return; - window.dispatchEvent(new CustomEvent(OPEN_SETTINGS_EVENT)); + window.dispatchEvent(new CustomEvent(OPEN_SETTINGS_EVENT, { detail: { focus } })); } diff --git a/public/sw.js b/public/sw.js index 068162b..b1680e8 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * * Bumping CACHE_VERSION is how a new deploy invalidates old assets. */ -const CACHE_VERSION = "freezmo-v24"; +const CACHE_VERSION = "freezmo-v25"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;