From 8822baf4b794d1654984ccabfd09dd58f4be7b1b Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Mon, 12 Oct 2026 20:40:57 +0000 Subject: [PATCH 1/2] Security: model budget + concurrency caps, SSRF guards, spoof-proof limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit pass with the threats that actually apply to a self-hosted, account-less app: getting the Ollama key throttled or banned, and someone grinding the app down. Per-route windows already existed; these are the missing layers. - lib/model-guard.ts: every route that spends a model call now takes a slot — 60 calls per window across all clients (RATE_LIMIT_MODEL_TOTAL), 3 in flight per IP, 6 in total — and the slot is released when the call finishes, on errors and on client disconnects. Over the concurrency cap answers 429 kind=busy, not a punishment. Slots are taken after every cheap rejection so a malformed request cannot hold one (found by the new suite: early 400s were leaking slots). - Images must be base64 data URLs (dataUrlSchema in lib/schemas): a plain URL in an images field made Ollama fetch an arbitrary address on our behalf. - Push endpoints must be a real push service (PUSH_ALLOWED_HOSTS, defaulting to FCM/Mozilla/Apple/WNS): the server POSTs to whatever is stored. - The rate-limit identity is the LAST x-forwarded-for hop. Reading the first let a client spoof its own IP and dodge every per-IP limit. - Disk guards: reports.jsonl stops at REPORTS_MAX_BYTES (8 MB), reminder devices cap at PUSH_MAX_SUBSCRIPTIONS (50). - Security headers: frame-ancestors 'none' + X-Frame-Options DENY, nosniff, a referrer policy and a Permissions-Policy without camera/mic/geo/payment. New tests/security-test.mjs (13 checks, registered in run-all) proves each guard against a slow stub Ollama with deliberately tiny limits. README: security notes + rate-limit tables updated. Version 0.1.3, SW -> v51. --- README.md | 44 ++++++- app/api/chat/extract/route.ts | 7 +- app/api/chat/route.ts | 8 ++ app/api/generate/route.ts | 12 +- app/api/grade/route.ts | 5 + app/api/homework/refine/route.ts | 5 + app/api/homework/route.ts | 14 ++- app/api/push/route.ts | 33 ++++- app/api/reports/route.ts | 11 +- lib/config.ts | 38 ++++++ lib/i18n-dict.ts | 6 + lib/model-guard.ts | 60 +++++++++ lib/push-store.ts | 13 +- lib/rate-limit.ts | 45 ++++++- lib/schemas.ts | 13 ++ next.config.ts | 20 +++ package.json | 2 +- public/sw.js | 2 +- tests/phase2-test.mjs | 2 + tests/run-all.mjs | 1 + tests/security-test.mjs | 208 +++++++++++++++++++++++++++++++ 21 files changed, 528 insertions(+), 21 deletions(-) create mode 100644 lib/model-guard.ts create mode 100644 tests/security-test.mjs diff --git a/README.md b/README.md index d80e47b..c6fa488 100644 --- a/README.md +++ b/README.md @@ -454,10 +454,31 @@ client IP with a sliding window. Everything is configurable in `.env.local`: | `GET/POST/DELETE /api/push`, `POST /api/push/test` | 30 per 5 minutes | `RATE_LIMIT_PUSH` | | window for all of them | 5 minutes | `RATE_LIMIT_WINDOW_MS` | +On top of the per-route windows there is one **account-level guard**, so no flood — from one client +or from many — can get the Ollama key throttled or banned: + +| Guard | Default | Env var | +| --- | --- | --- | +| all model calls together (generate, homework, refine, chat, extract, grade) | 60 per window | `RATE_LIMIT_MODEL_TOTAL` | +| model calls in flight per client IP | 3 | `RATE_LIMIT_CONCURRENT` | +| model calls in flight across all clients | 6 | `RATE_LIMIT_CONCURRENT_TOTAL` | + +A request over the concurrency cap gets a **429 `kind: "busy"`** (“Freezmo is already working on +something — try again in a moment”), not a punishment: the slot frees as soon as the model call +finishes, and the client's Retry button works. Slots are taken *after* every cheap rejection, so a +malformed request can never hold one. + +Two more disk guards: `REPORTS_MAX_BYTES` (default 8 MB) stops appending to `reports.jsonl` once it +is full, and `PUSH_MAX_SUBSCRIPTIONS` (default 50) refuses new reminder devices over the cap. +Reminder endpoints must also be a real push service — `fcm.googleapis.com`, +`updates.push.services.mozilla.com`, `*.push.apple.com`, `*.notify.windows.com` — or a host you add +with `PUSH_ALLOWED_HOSTS`, because the server POSTs to whatever is stored. + When a limit is hit the route answers **429** with a `Retry-After` header and a friendly message (“Too many requests — try again in 42s”); the UI shows that message and keeps the Retry button. The -client IP is taken from `x-forwarded-for` / `x-real-ip` when present (reverse proxy), otherwise all -local clients share one bucket. +client IP is taken from the **last** `x-forwarded-for` hop (the one your reverse proxy appended) or +from `x-real-ip`; taking the first hop would let a client spoof its own identity and dodge every +per-IP limit. Requests without those headers share one bucket. Caveats, stated honestly: counters live **in memory**, so they reset on restart and are not shared between multiple instances or Node workers. That is fine for a self-hosted single-instance app; if @@ -658,6 +679,19 @@ self-hosted study app with no accounts, no database and no server-side state. parsing, on top of the zod limits on image count, text length and field sizes. - **Rate limiting** per client IP; see [Rate limits](#rate-limits). Auth is checked *before* parsing request bodies, so unauthenticated traffic costs almost nothing. +- **An account-level model budget and concurrency caps** sit in front of every route that spends an + Ollama call (`lib/model-guard.ts`): 60 calls per window across all clients, 3 in flight per IP, 6 + in flight in total. A flood cannot pile up parallel calls or burn the account — the thing that + gets a key throttled or banned. Slots are released when the call finishes, including a client that + disconnects mid-stream. +- **Images must be base64 data URLs** (`dataUrlSchema`). A plain URL in an `images` field would make + Ollama fetch an arbitrary address on our behalf, so the shape is part of the contract. +- **Reminder endpoints must be real push services** (an allowlist, `PUSH_ALLOWED_HOSTS`): the server + POSTs to whatever is stored, so an arbitrary URL there would be an SSRF primitive. +- **Security headers** on every response: `X-Frame-Options: DENY`, `Content-Security-Policy: + frame-ancestors 'none'`, `X-Content-Type-Options: nosniff`, a referrer policy and a + Permissions-Policy that turns off camera/microphone/geolocation/payment (the camera button is a + file input with `capture`, which is not gated by that policy). - **No CSRF surface**: no cookies, no sessions, no server-side mutations. - **Prompt injection is low impact by design.** Your notes could contain text that tries to manipulate the model, but the model can only return a JSON deck (validated per activity, dropped if @@ -670,8 +704,10 @@ self-hosted study app with no accounts, no database and no server-side state. notes. - Anything stored in `localStorage` is readable by JavaScript on the same origin. Freezmo has no XSS vector today, but that is the standard risk of browser-stored credentials. -- Rate limiting trusts `x-forwarded-for` when present (correct behind a proxy, spoofable when - exposed directly) and keeps counters in memory: single instance only, reset on restart. +- Rate limiting keeps counters in memory: single instance only, reset on restart. The client IP is + read from the **last** `x-forwarded-for` hop, so a client cannot spoof its identity — but behind + more than one proxy the "last hop" is the proxy closest to Freezmo, which means those clients + share one bucket. Point `x-real-ip` at the real client if you need per-client buckets there. - Ollama cloud usage is billed to whoever's key is used, so treat a shared Freezmo URL as shared key access in `server` mode. - No accounts means no per-user isolation: anyone who can reach the URL can use the app (and, in diff --git a/app/api/chat/extract/route.ts b/app/api/chat/extract/route.ts index 4b93cfa..279e4f4 100644 --- a/app/api/chat/extract/route.ts +++ b/app/api/chat/extract/route.ts @@ -5,6 +5,8 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@ import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { acquireModelGate } from "@/lib/model-guard"; +import { dataUrlSchema } from "@/lib/schemas"; import { translate } from "@/lib/i18n-dict"; export const runtime = "nodejs"; @@ -16,7 +18,7 @@ const MAX_BODY_BYTES = 33554432; const MAX_IMAGE_CHARS = 8_000_000; const requestSchema = z.object({ - images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(), + images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), text: z.string().max(120_000).optional(), }); @@ -66,6 +68,8 @@ export async function POST(request: Request) { return Response.json({ error: translate(locale, "server.needContent") }, { status: 400 }); } + const gate = acquireModelGate(request, config, locale); + if ("response" in gate) return gate.response; const abort = new AbortController(); const timer = setTimeout(() => abort.abort(), config.timeoutMs); try { @@ -97,6 +101,7 @@ export async function POST(request: Request) { ); } finally { clearTimeout(timer); + gate.gate.release(); } } diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 9e03022..ae86e43 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -4,6 +4,7 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM import { friendlyErrorMessage, ollamaChatStream, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { acquireModelGate } from "@/lib/model-guard"; import { translate } from "@/lib/i18n-dict"; import { buildChatSystemPrompt } from "@/lib/prompts"; import { estimateTokens, trimHistory } from "@/lib/chat-trim"; @@ -131,6 +132,11 @@ export async function POST(request: Request) { })), }); + // Concurrency + the account-wide model budget (lib/model-guard), taken + // after every cheap rejection so a 400 can never leak a slot. + const gate = acquireModelGate(request, config, locale); + if ("response" in gate) return gate.response; + const encode = encoder(); const abort = new AbortController(); const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs); @@ -170,6 +176,7 @@ export async function POST(request: Request) { } } finally { clearTimeout(globalTimer); + gate.gate.release(); try { controller.close(); } catch { @@ -179,6 +186,7 @@ export async function POST(request: Request) { }, cancel() { clearTimeout(globalTimer); + gate.gate.release(); abort.abort(); }, }); diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts index 7c5e9e3..82c3951 100644 --- a/app/api/generate/route.ts +++ b/app/api/generate/route.ts @@ -5,8 +5,9 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { acquireModelGate } from "@/lib/model-guard"; import { translate } from "@/lib/i18n-dict"; -import { difficultySchema } from "@/lib/schemas"; +import { dataUrlSchema, difficultySchema } from "@/lib/schemas"; import { uid } from "@/lib/utils"; import { xpForActivity } from "@/lib/xp"; @@ -18,7 +19,7 @@ export const maxDuration = 300; const MAX_IMAGE_CHARS = 8_000_000; const requestSchema = z.object({ - images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(), + images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), text: z.string().max(120_000).optional(), difficulty: difficultySchema.default("medium"), count: z.number().int().min(4).max(18).optional(), @@ -101,6 +102,11 @@ export async function POST(request: Request) { ); } + // Concurrency + the account-wide model budget (lib/model-guard). Taken + // only now, after every cheap rejection, so a 400 can never leak a slot. + const gate = acquireModelGate(request, config, locale); + if ("response" in gate) return gate.response; + const requestedCount = count ?? config.defaultActivityCount; const encode = encoder(); const abort = new AbortController(); @@ -201,6 +207,7 @@ export async function POST(request: Request) { }); } finally { clearTimeout(globalTimer); + gate.gate.release(); try { controller.close(); } catch { @@ -210,6 +217,7 @@ export async function POST(request: Request) { }, cancel() { clearTimeout(globalTimer); + gate.gate.release(); abort.abort(); }, }); diff --git a/app/api/grade/route.ts b/app/api/grade/route.ts index a04c46c..8590364 100644 --- a/app/api/grade/route.ts +++ b/app/api/grade/route.ts @@ -5,6 +5,7 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@ import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { acquireModelGate } from "@/lib/model-guard"; import { translate } from "@/lib/i18n-dict"; export const runtime = "nodejs"; @@ -54,6 +55,8 @@ export async function POST(request: Request) { return Response.json({ error: translate(locale, "server.invalidGrade") }, { status: 400 }); } + const gate = acquireModelGate(request, config, locale); + if ("response" in gate) return gate.response; try { const result = await gradeShortAnswer({ model: config.textModel, @@ -72,5 +75,7 @@ export async function POST(request: Request) { }, { status: 502 }, ); + } finally { + gate.gate.release(); } } diff --git a/app/api/homework/refine/route.ts b/app/api/homework/refine/route.ts index 8fc04ad..0435e98 100644 --- a/app/api/homework/refine/route.ts +++ b/app/api/homework/refine/route.ts @@ -6,6 +6,7 @@ import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { translate } from "@/lib/i18n-dict"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { acquireModelGate } from "@/lib/model-guard"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -64,6 +65,8 @@ export async function POST(request: Request) { ); } + const gate = acquireModelGate(request, config, locale); + if ("response" in gate) return gate.response; try { const text = await refineHomeworkText({ model: config.textModel, @@ -86,5 +89,7 @@ export async function POST(request: Request) { }, { status: 502 }, ); + } finally { + gate.gate.release(); } } diff --git a/app/api/homework/route.ts b/app/api/homework/route.ts index 1a0a535..761a603 100644 --- a/app/api/homework/route.ts +++ b/app/api/homework/route.ts @@ -5,8 +5,9 @@ import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveM import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { acquireModelGate } from "@/lib/model-guard"; import { translate } from "@/lib/i18n-dict"; -import { homeworkModeSchema, type HomeworkStep } from "@/lib/schemas"; +import { dataUrlSchema, homeworkModeSchema, type HomeworkStep } from "@/lib/schemas"; import { uid } from "@/lib/utils"; export const runtime = "nodejs"; @@ -20,12 +21,12 @@ const MAX_BODY_BYTES = 33554432; const requestSchema = z.object({ mode: homeworkModeSchema, - images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(), + images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), text: z.string().max(120_000).optional(), /** Mode "check": the student's own attempt at the assignment. */ solution: z.string().max(120_000).optional(), /** Mode "check": photos of the attempt (handwritten work, scanned pages). */ - solutionImages: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(), + solutionImages: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), /** Language of the assignment (usually detected by the vision step). */ language: z.string().max(60).optional(), }); @@ -95,6 +96,11 @@ export async function POST(request: Request) { return Response.json({ error: translate(locale, "server.needSolution") }, { status: 400 }); } + // Concurrency + the account-wide model budget (lib/model-guard), taken + // after every cheap rejection so a 400 can never leak a slot. + const gate = acquireModelGate(request, config, locale); + if ("response" in gate) return gate.response; + const encode = encoder(); const abort = new AbortController(); // A single hard deadline for the whole pipeline (two sequential model calls). @@ -257,6 +263,7 @@ export async function POST(request: Request) { }); } finally { clearTimeout(globalTimer); + gate.gate.release(); try { controller.close(); } catch { @@ -266,6 +273,7 @@ export async function POST(request: Request) { }, cancel() { clearTimeout(globalTimer); + gate.gate.release(); abort.abort(); }, }); diff --git a/app/api/push/route.ts b/app/api/push/route.ts index e7b8c78..d8c8451 100644 --- a/app/api/push/route.ts +++ b/app/api/push/route.ts @@ -16,6 +16,23 @@ export const dynamic = "force-dynamic"; * is false — no half-configured UI ever shows. */ +/** + * The server POSTs to the stored endpoint with VAPID headers, so it must be a + * real push service: an arbitrary URL here would be an SSRF primitive. The + * allowed hosts come from the config (PUSH_ALLOWED_HOSTS), defaulting to the + * real services. + */ +function isPushEndpoint(value: string, allowedHosts: string[]): boolean { + try { + const url = new URL(value); + if (url.protocol !== "https:") return false; + const host = url.hostname.toLowerCase(); + return allowedHosts.some((entry) => (entry.startsWith(".") ? host.endsWith(entry) : host === entry)); + } catch { + return false; + } +} + const subscriptionSchema = z.object({ endpoint: z.string().url().max(2000), keys: z.object({ @@ -96,9 +113,21 @@ export async function POST(request: Request) { { status: 400 }, ); } + if (!isPushEndpoint(parsed.data.endpoint, config.push.allowedHosts)) { + return Response.json( + { error: translate(locale, "server.pushEndpoint"), kind: "bad_endpoint" }, + { status: 400 }, + ); + } - const count = await addSubscription(config.push.dir, parsed.data); - return Response.json({ ok: true, count }, { headers: { "Cache-Control": "no-store" } }); + const count = await addSubscription(config.push.dir, parsed.data, config.push.maxSubscriptions); + if (!count.accepted) { + return Response.json( + { error: translate(locale, "server.pushTooMany", { max: config.push.maxSubscriptions }), kind: "too_many" }, + { status: 429 }, + ); + } + return Response.json({ ok: true, count: count.count }, { headers: { "Cache-Control": "no-store" } }); } export async function DELETE(request: Request) { diff --git a/app/api/reports/route.ts b/app/api/reports/route.ts index 263d246..bb92e7e 100644 --- a/app/api/reports/route.ts +++ b/app/api/reports/route.ts @@ -51,7 +51,16 @@ export async function POST(request: Request) { const line = JSON.stringify({ ...parsed.data, receivedAt: new Date().toISOString() }); try { await fs.mkdir(config.reports.dir, { recursive: true }); - await fs.appendFile(path.join(config.reports.dir, "reports.jsonl"), `${line}\n`, "utf8"); + const file = path.join(config.reports.dir, "reports.jsonl"); + /* Disk guard: reports are a convenience for the operator, so once the + file passes the cap the app stops appending (the browser's own list is + still the source of truth for skipping). */ + const size = await fs.stat(file).then((stats) => stats.size).catch(() => 0); + if (size >= config.reports.maxBytes) { + console.warn("[api/reports] reports.jsonl is at its size cap — not appending"); + return Response.json({ ok: true }); + } + await fs.appendFile(file, `${line}\n`, "utf8"); } catch { return Response.json({ error: "could not store the report" }, { status: 500 }); } diff --git a/lib/config.ts b/lib/config.ts index 1e4468a..6d76cfd 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -37,6 +37,12 @@ export type ServerConfig = { push: number; chat: number; reports: number; + /** All Ollama calls together per window (account-level guard). */ + modelTotal: number; + /** In-flight model calls per client IP. */ + concurrent: number; + /** In-flight model calls across all clients. */ + concurrentTotal: number; }; sync: { /** Where opaque encrypted sync blobs live (SYNC_DIR). */ @@ -53,10 +59,20 @@ export type ServerConfig = { subject: string; /** Where push subscriptions live (PUSH_DIR). */ dir: string; + /** Cap on stored subscriptions (PUSH_MAX_SUBSCRIPTIONS). */ + maxSubscriptions: number; + /** + * Hosts the server is allowed to POST reminders to (PUSH_ALLOWED_HOSTS, + * comma separated). Defaults to the real push services; a self-hosted + * push service can be added, and the test suite points it at its own stub. + */ + allowedHosts: string[]; }; reports: { /** Where reported questions are appended, one JSON line each (REPORTS_DIR). */ dir: string; + /** Stop appending past this size (REPORTS_MAX_BYTES) — disk guard. */ + maxBytes: number; }; }; @@ -156,6 +172,12 @@ export function getServerConfig(): ServerConfig { chat: positiveInt(process.env.RATE_LIMIT_CHAT, 60), // Question reports are cheap file appends. reports: positiveInt(process.env.RATE_LIMIT_REPORTS, 20), + // Every Ollama call, all clients together: the account-level guard. A + // flood would otherwise get the key throttled or banned. + modelTotal: positiveInt(process.env.RATE_LIMIT_MODEL_TOTAL, 60), + // In-flight model calls: per client IP, and across all clients. + concurrent: positiveInt(process.env.RATE_LIMIT_CONCURRENT, 3), + concurrentTotal: positiveInt(process.env.RATE_LIMIT_CONCURRENT_TOTAL, 6), }, sync: { dir: process.env.SYNC_DIR?.trim() || path.join(process.cwd(), ".freezmo", "sync"), @@ -167,9 +189,25 @@ export function getServerConfig(): ServerConfig { privateKey: process.env.VAPID_PRIVATE_KEY?.trim() || "", subject: process.env.VAPID_SUBJECT?.trim() || "mailto:freezmo@localhost", dir: process.env.PUSH_DIR?.trim() || path.join(process.cwd(), ".freezmo", "push"), + maxSubscriptions: positiveInt(process.env.PUSH_MAX_SUBSCRIPTIONS, 50), + allowedHosts: (process.env.PUSH_ALLOWED_HOSTS ?? "") + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean) + .concat( + process.env.PUSH_ALLOWED_HOSTS + ? [] + : [ + "fcm.googleapis.com", + "updates.push.services.mozilla.com", + ".push.apple.com", + ".notify.windows.com", + ], + ), }, reports: { dir: process.env.REPORTS_DIR?.trim() || path.join(process.cwd(), ".freezmo", "reports"), + maxBytes: positiveInt(process.env.REPORTS_MAX_BYTES, 8 * 1024 * 1024), }, }; } diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts index bf4844f..583f4f2 100644 --- a/lib/i18n-dict.ts +++ b/lib/i18n-dict.ts @@ -742,6 +742,9 @@ const en: Record = { "server.invalidOrigin": "Invalid Origin header.", "server.crossOrigin": "Cross-origin requests are not allowed.", "server.rateLimited": "Too many requests — this app allows {limit} per window. Try again in {retry}s.", + "server.busy": "Freezmo is already working on something — try again in a moment.", + "server.pushTooMany": "This server already stores {max} reminder devices — remove one first.", + "server.pushEndpoint": "That is not a push service this server is allowed to send to.", "server.authMissingKey": "Add your Ollama API key first — open the settings dialog (⚙️) and paste a key from ollama.com/settings/keys.", "server.authServerKeyMissing": @@ -1681,6 +1684,9 @@ const cs: Record = { "server.invalidOrigin": "Neplatná hlavička Origin.", "server.crossOrigin": "Požadavky z jiné domény nejsou povoleny.", "server.rateLimited": "Příliš mnoho požadavků — aplikace povoluje {limit} za okno. Zkus to znovu za {retry} s.", + "server.busy": "Freezmo už na něčem pracuje — zkus to za chvilku znovu.", + "server.pushTooMany": "Server už má uložených {max} zařízení pro připomínky — nejdřív nějaké odeber.", + "server.pushEndpoint": "Na tuhle službu server připomínky posílat nesmí.", "server.authMissingKey": "Nejdřív přidej svůj API klíč Ollama — otevři nastavení (⚙️) a vlož klíč z ollama.com/settings/keys.", "server.authServerKeyMissing": diff --git a/lib/model-guard.ts b/lib/model-guard.ts new file mode 100644 index 0000000..bd868a8 --- /dev/null +++ b/lib/model-guard.ts @@ -0,0 +1,60 @@ +import type { ServerConfig } from "./config"; +import { translate, type Locale } from "./i18n-dict"; +import { acquireSlot, clientKey, rateLimit, rateLimitResponse, releaseSlot } from "./rate-limit"; + +/** + * One gate in front of every route that spends an Ollama call: + * + * - a **global budget** per window (all clients together) so no flood can + * burn through the account — the thing that gets a key throttled or banned; + * - a **per-IP concurrency cap** and a **global one**, so a burst of parallel + * requests can't pile up model calls (or sockets and memory). + * + * The slot is released once, when the response is finished — including a + * client that disconnects mid-stream, and including routes that throw. + */ +export type ModelGate = { release: () => void }; + +export function acquireModelGate( + request: Request, + config: ServerConfig, + locale: Locale, +): { gate: ModelGate } | { response: Response } { + const perIpKey = `model-ip:${clientKey(request)}`; + const totalKey = "model-total"; + + if (!acquireSlot(totalKey, config.rateLimits.concurrentTotal)) { + return { response: busyResponse(locale) }; + } + if (!acquireSlot(perIpKey, config.rateLimits.concurrent)) { + releaseSlot(totalKey); + return { response: busyResponse(locale) }; + } + + const budget = rateLimit("model-budget", config.rateLimits.modelTotal, config.rateLimits.windowMs); + if (!budget.allowed) { + releaseSlot(perIpKey); + releaseSlot(totalKey); + return { response: rateLimitResponse(budget, locale) }; + } + + let released = false; + return { + gate: { + release: () => { + if (released) return; + released = true; + releaseSlot(perIpKey); + releaseSlot(totalKey); + }, + }, + }; +} + +/** 429 for "something is already running" — not a rate-limit punishment. */ +function busyResponse(locale: Locale): Response { + return Response.json( + { error: translate(locale, "server.busy"), kind: "busy", retryAfter: 5 }, + { status: 429, headers: { "Retry-After": "5", "Cache-Control": "no-store" } }, + ); +} diff --git a/lib/push-store.ts b/lib/push-store.ts index 1e9c749..7259c44 100644 --- a/lib/push-store.ts +++ b/lib/push-store.ts @@ -46,14 +46,19 @@ async function writeSubscriptions(dir: string, next: PushSubscriptionRecord[]): export async function addSubscription( dir: string, subscription: { endpoint: string; keys: { p256dh: string; auth: string } }, -): Promise { + /** Cap on stored subscriptions — the configured, sane one. */ + max = 50, +): Promise<{ count: number; accepted: boolean }> { const current = await readSubscriptions(dir); + const exists = current.some((entry) => entry.endpoint === subscription.endpoint); const next = current.filter((entry) => entry.endpoint !== subscription.endpoint); + // Refreshing a device that is already subscribed always works; a brand new + // device over the cap is refused. + if (!exists && next.length >= max) return { count: current.length, accepted: false }; next.push({ ...subscription, createdAt: Date.now() }); - // A cap keeps a runaway script from filling the disk. - const capped = next.slice(-2000); + const capped = next.slice(-max); await writeSubscriptions(dir, capped); - return capped.length; + return { count: capped.length, accepted: true }; } export async function removeSubscription(dir: string, endpoint: string): Promise { diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index 120206b..824c290 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -73,12 +73,53 @@ function prune(now: number, windowMs: number): void { } } +/* ------------------------------------------------------------------ */ +/* In-flight slots (concurrency, not per-window) */ +/* ------------------------------------------------------------------ */ + +/** + * Per-window limits alone let a client fire every allowed request at once: + * ten parallel model calls still hammer Ollama (and hold ten sockets). These + * counters cap what is running *right now*. Kept on globalThis for the same + * reason as the buckets. + */ +const slotStore = globalThis as unknown as { __freezmoSlots?: Map }; +const slots: Map = (slotStore.__freezmoSlots ??= new Map()); + +/** Take one of `max` in-flight slots for `key`. */ +export function acquireSlot(key: string, max: number): boolean { + const current = slots.get(key) ?? 0; + if (current >= max) return false; + slots.set(key, current + 1); + return true; +} + +/** Give an in-flight slot back. */ +export function releaseSlot(key: string): void { + const current = slots.get(key) ?? 0; + if (current <= 1) slots.delete(key); + else slots.set(key, current - 1); +} + +/** How many slots are in flight for a key (diagnostics and tests). */ +export function inFlight(key: string): number { + return slots.get(key) ?? 0; +} + /** Best-effort client identity: proxy headers first, then a shared bucket. */ export function clientKey(request: Request): string { const forwarded = request.headers.get("x-forwarded-for"); if (forwarded) { - const first = forwarded.split(",")[0]?.trim(); - if (first) return first; + // Take the **last** hop, not the first: our reverse proxy appends the peer + // it saw, so the last entry is the one connection we can trust. Reading the + // first entry would let a client send its own `x-forwarded-for` and dodge + // every per-IP limit. + const hops = forwarded + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + const last = hops.at(-1); + if (last) return last; } return ( request.headers.get("x-real-ip") ?? diff --git a/lib/schemas.ts b/lib/schemas.ts index 005c717..7f43aa8 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -8,6 +8,19 @@ import { z } from "zod"; export const difficultySchema = z.enum(["easy", "medium", "hard"]); export type Difficulty = z.infer; +/** + * Photos and rendered pages travel as base64 data URLs — never plain URLs. + * A URL here would make Ollama fetch an arbitrary address on our behalf (an + * SSRF hole through the model host), so the shape is part of the contract. + */ +export function dataUrlSchema(maxChars: number) { + return z + .string() + .min(32) + .max(maxChars) + .regex(/^data:[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*;base64,/i, "must be a base64 data URL"); +} + const baseActivity = { /** Short label shown in the feed chrome, e.g. "Cell biology". */ topic: z.string().min(1).max(120).optional(), diff --git a/next.config.ts b/next.config.ts index 0d9cebb..d5af961 100644 --- a/next.config.ts +++ b/next.config.ts @@ -23,6 +23,26 @@ const nextConfig: NextConfig = { * the e2e suites are unaffected. */ output: "standalone", + /** + * Security headers. The app has no accounts, so these are about containment: + * never framed (clickjacking), never sniffed into a script, and no camera, + * microphone or location access it does not need (the camera button uses a + * file input with `capture`, which is not gated by Permissions-Policy). + */ + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "X-Frame-Options", value: "DENY" }, + { key: "Content-Security-Policy", value: "frame-ancestors 'none'" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=()" }, + ], + }, + ]; + }, allowedDevOrigins: [ "127.0.0.1", "localhost", diff --git a/package.json b/package.json index 5528d2e..7c12ce6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freezmo", - "version": "0.1.2", + "version": "0.1.3", "private": true, "license": "GPL-3.0-only", "scripts": { diff --git a/public/sw.js b/public/sw.js index 6f0c56f..bf3e14c 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-v50"; +const CACHE_VERSION = "freezmo-v51"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/tests/phase2-test.mjs b/tests/phase2-test.mjs index 808b1a0..3edd76f 100644 --- a/tests/phase2-test.mjs +++ b/tests/phase2-test.mjs @@ -447,6 +447,8 @@ const browser = await launchBrowser(); VAPID_PRIVATE_KEY: vapid.privateKey, VAPID_SUBJECT: "mailto:test@example.com", PUSH_DIR: path.join(REPO, "tests", ".tmp", "push"), + // The endpoint in this suite is a fake host: allow it explicitly. + PUSH_ALLOWED_HOSTS: "push.example.invalid", }); try { diff --git a/tests/run-all.mjs b/tests/run-all.mjs index 8c8dcce..5961736 100644 --- a/tests/run-all.mjs +++ b/tests/run-all.mjs @@ -22,6 +22,7 @@ const SUITES = [ "phase1-test", "phase2-test", "phase3-test", + "security-test", "chat-test", "review-test", ]; diff --git a/tests/security-test.mjs b/tests/security-test.mjs new file mode 100644 index 0000000..fd55fe8 --- /dev/null +++ b/tests/security-test.mjs @@ -0,0 +1,208 @@ +/** + * Security limits: the model gate (per-IP concurrency + the account-wide + * budget), the data-URL contract, the proxy-hop identity, the disk guards and + * the response headers. + * + * Runs against its own instance with a slow stub Ollama and deliberately tiny + * limits, so every guard is observable in a second or two. + */ +import http from "node:http"; +import { mkdirSync, readFileSync, rmSync } from "node:fs"; + +import { launchBrowser, makeChecker, makePage, portInUse, seed, spawnApp, waitForHttp } from "./lib.mjs"; + +const { check, finish } = makeChecker(); + +const STUB_PORT = 11997; +const APP_PORT = 3210; +const STUB_BASE = `http://127.0.0.1:${STUB_PORT}`; +const APP = `http://127.0.0.1:${APP_PORT}`; + +const REPORTS_DIR = new URL("./.tmp/security-reports", import.meta.url).pathname; +const PUSH_DIR = new URL("./.tmp/security-push", import.meta.url).pathname; + +/* A deliberately slow stub: model calls stay in flight long enough to test + the concurrency gate, and the payload itself never matters here. */ +const stub = http.createServer((request, response) => { + if (request.url?.startsWith("/api/tags")) { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ models: [{ name: "stub", model: "stub" }] })); + return; + } + request.on("data", () => {}); + request.on("end", () => { + setTimeout(() => { + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ message: { content: "{}" } })); + }, 700); + }); +}); +await new Promise((resolve) => stub.listen(STUB_PORT, "127.0.0.1", resolve)); + +rmSync(REPORTS_DIR, { recursive: true, force: true }); +rmSync(PUSH_DIR, { recursive: true, force: true }); +mkdirSync(REPORTS_DIR, { recursive: true }); +mkdirSync(PUSH_DIR, { recursive: true }); + +check("security app port is free", !(await portInUse(APP_PORT))); +const app = spawnApp(APP_PORT, { + OLLAMA_BASE_URL: STUB_BASE, + OLLAMA_AUTH: "local", + OLLAMA_VERIFY_KEY: "false", + OLLAMA_TIMEOUT_MS: "10000", + RATE_LIMIT_CONCURRENT: "2", + RATE_LIMIT_CONCURRENT_TOTAL: "3", + RATE_LIMIT_MODEL_TOTAL: "6", + RATE_LIMIT_REPORTS: "1", + REPORTS_DIR, + REPORTS_MAX_BYTES: "900", + PUSH_DIR, + PUSH_MAX_SUBSCRIPTIONS: "2", + VAPID_PUBLIC_KEY: "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U", + VAPID_PRIVATE_KEY: "UUxI4O8-FbRouAevSmBQ6o18hgE4nSG3qwvJTfKc-ls", + VAPID_SUBJECT: "mailto:test@example.com", +}); + +const browser = await launchBrowser(); + +try { + const up = await waitForHttp(`${APP}/api/health`); + check("security app instance is up", up); + + if (up) { + /* 1. per-IP concurrency: only N model calls in flight ---------------- */ + const generate = (body) => + fetch(`${APP}/api/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: "Photosynthesis needs light and water.", ...body }), + }); + const parallel = await Promise.all([generate({}), generate({}), generate({})]); + const statuses = parallel.map((response) => response.status).sort(); + check( + "concurrency: the third parallel model call is refused as busy", + statuses.filter((status) => status === 200).length === 2 && statuses.includes(429), + JSON.stringify(statuses), + ); + const busy = await parallel.find((response) => response.status === 429)?.json(); + check("concurrency: the refusal is a friendly busy 429", busy?.kind === "busy" && typeof busy?.error === "string", JSON.stringify(busy)); + // Drain the two streams so their slots are released. + await Promise.all(parallel.map((response) => response.text().catch(() => ""))); + + /* 2. the account-wide budget: even sequential calls stop ------------ + Two calls were admitted above (the refused one never counted), and the + data-URL call below adds one — so with a budget of six, three more get + through and the next is refused. */ + const plainUrl = await fetch(`${APP}/api/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ images: [`http://169.254.169.254/latest/meta-data/`, "x".repeat(40)] }), + }); + check("data URLs: a plain URL is rejected", plainUrl.status === 400, String(plainUrl.status)); + const dataUrl = await fetch(`${APP}/api/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ images: [`data:image/jpeg;base64,${"A".repeat(200)}`] }), + }); + check("data URLs: a real data URL is accepted", dataUrl.status === 200, String(dataUrl.status)); + await dataUrl.text().catch(() => ""); + + const admitted = []; + for (let index = 0; index < 3; index += 1) { + const response = await generate({}); + admitted.push(response.status); + await response.text().catch(() => ""); + } + const overBudget = await generate({}); + const overBudgetBody = overBudget.status === 429 ? await overBudget.json() : null; + check( + "budget: the model-call budget stops a flood", + admitted.every((status) => status === 200) && overBudget.status === 429 && overBudgetBody?.kind === "rate_limited", + `${JSON.stringify(admitted)} then ${overBudget.status} ${JSON.stringify(overBudgetBody)}`, + ); + + /* 4. the rate-limit identity is the last proxy hop ------------------ */ + const report = (forwarded) => + fetch(`${APP}/api/reports`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-forwarded-for": forwarded }, + body: JSON.stringify({ + deckId: "seed_deck", + activityId: "a1", + type: "multiple_choice", + question: "What colour is a clear daytime sky?", + at: Date.now(), + }), + }); + const spoofOne = await report("9.9.9.9, 10.0.0.1"); + const spoofTwo = await report("8.8.8.8, 10.0.0.1"); + const other = await report("7.7.7.7, 10.0.0.2"); + check( + "identity: a spoofed x-forwarded-for cannot dodge the per-IP limit", + spoofOne.status === 200 && spoofTwo.status === 429 && other.status === 200, + `${spoofOne.status}/${spoofTwo.status}/${other.status}`, + ); + + /* 5. the reports file stops growing at its cap ---------------------- */ + for (let index = 0; index < 6; index += 1) { + await report(`7.7.7.7, 10.0.0.${10 + index}`); + } + const reportsSize = readFileSync(`${REPORTS_DIR}/reports.jsonl`, "utf8").length; + check("reports: the JSONL stops at its size cap", reportsSize > 0 && reportsSize < 2000, `${reportsSize} bytes`); + + /* 6. push endpoints must be real push services ---------------------- */ + const subscribe = (endpoint) => + fetch(`${APP}/api/push`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint, keys: { p256dh: "B".repeat(40), auth: "C".repeat(20) } }), + }); + const evil = await subscribe("https://evil.example/collect"); + check("push: an arbitrary endpoint is rejected (no SSRF)", evil.status === 400, String(evil.status)); + const good = await subscribe("https://fcm.googleapis.com/fcm/send/abc"); + const goodSecond = await subscribe("https://updates.push.services.mozilla.com/wpush/v2/abc"); + const overCap = await subscribe("https://web.push.apple.com/abc"); + check( + "push: subscriptions are capped", + good.status === 200 && goodSecond.status === 200 && overCap.status === 429, + `${good.status}/${goodSecond.status}/${overCap.status}`, + ); + + /* 7. response headers ------------------------------------------------ */ + const { context, page } = await makePage(browser); + await seed(page, { decks: [] }); + await page.goto(`${APP}/`, { waitUntil: "domcontentloaded" }); + const headers = await page.evaluate(async () => { + const response = await fetch("/", { cache: "no-store" }); + return { + frame: response.headers.get("x-frame-options"), + csp: response.headers.get("content-security-policy"), + nosniff: response.headers.get("x-content-type-options"), + referrer: response.headers.get("referrer-policy"), + }; + }); + check( + "headers: the app refuses to be framed and sniffed", + headers.frame === "DENY" && + (headers.csp ?? "").includes("frame-ancestors 'none'") && + headers.nosniff === "nosniff" && + headers.referrer === "strict-origin-when-cross-origin", + JSON.stringify(headers), + ); + + /* 8. cross-origin writes stay rejected ------------------------------- */ + const crossOrigin = await fetch(`${APP}/api/reports`, { + method: "POST", + headers: { "Content-Type": "application/json", Origin: "https://evil.example" }, + body: JSON.stringify({ deckId: "d", activityId: "a", type: "multiple_choice", question: "q", at: Date.now() }), + }); + check("cross-origin POST is refused", crossOrigin.status === 403, String(crossOrigin.status)); + await context.close(); + } +} finally { + app.stop(); + stub.close(); + await browser.close(); +} + +finish(); From df34337b4ac5f7291fe329ea78b31a1d3055bf8c Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Mon, 12 Oct 2026 21:15:56 +0000 Subject: [PATCH 2/2] Accessibility pass, report control for homework exercises, clearer rail actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - axe is clean across every screen in both themes, phone and desktop. Fixed: the hidden file inputs (aria-hidden + tabIndex -1 — they are opened by labelled buttons, so they should not be in the accessibility tree), the heading order (deck/homework/smart-review cards are h2 under the page h1; LearningSection takes a level so it stays h2 on the library and h3 inside the settings dialog) and landmarks around the study and review feeds. - tests/contrast.mjs (new diagnostic): samples rendered pixels, because axe cannot judge text on blurred glass — it reports those nodes as incomplete. It skips disabled controls (1.4.3 exempts them) and content behind an open sheet. Result: no below-minimum text on any scanned screen. - The report control exists in the homework guide too: under each "Try it yourself" exercise, labelled "Report exercise", without a skip (there is no queue to skip). lib/question-reports now takes deckId/deckTitle directly. - The rail's two creation actions are buttons now (.nav-action) rather than quiet nav rows — once the library is full they were the only way to start something on desktop and read as just more navigation. "Make my homework" is "New homework" wherever it is an action (rail, empty state, share chooser, manifest shortcut); the page title and mode switch keep their wording. Version 0.1.6 (three task bumps), SW -> freezmo-v52. --- app/deck/[id]/page.tsx | 5 +- app/globals.css | 22 ++++ app/manifest.ts | 2 +- components/DataSection.tsx | 2 + components/DeckCard.tsx | 4 +- components/HomeworkCard.tsx | 4 +- components/LearningSection.tsx | 13 +- components/LibraryScreen.tsx | 2 + components/ReportQuestionButton.tsx | 31 +++-- components/ReviewClient.tsx | 63 +++++----- components/SessionFeed.tsx | 3 +- components/SettingsDialog.tsx | 4 +- components/Sidebar.tsx | 13 +- components/StudyFeed.tsx | 3 +- components/UploadScreen.tsx | 8 ++ components/chat/Composer.tsx | 2 + components/homework/HomeworkGuide.tsx | 13 +- components/homework/HomeworkInput.tsx | 12 ++ lib/i18n-dict.ts | 10 +- lib/question-reports.ts | 11 +- package.json | 2 +- public/sw.js | 2 +- tests/contrast.mjs | 169 ++++++++++++++++++++++++++ tests/mobile-test.mjs | 4 +- tests/native-test.mjs | 2 +- tests/regression.mjs | 4 +- tests/wizard-test.mjs | 2 +- 27 files changed, 337 insertions(+), 75 deletions(-) create mode 100644 tests/contrast.mjs diff --git a/app/deck/[id]/page.tsx b/app/deck/[id]/page.tsx index bb6eac8..5927fad 100644 --- a/app/deck/[id]/page.tsx +++ b/app/deck/[id]/page.tsx @@ -72,7 +72,10 @@ export default function DeckPage() { return ( - + {/* The feed is a fixed overlay, but its content still needs a landmark. */} +
+ +
); } diff --git a/app/globals.css b/app/globals.css index 4357eb8..c5d4249 100644 --- a/app/globals.css +++ b/app/globals.css @@ -647,6 +647,28 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar) { background: var(--hover); color: var(--text); } + +/* The rail's two creation actions ("New deck", "New homework") are buttons, + not nav rows: once the library is full the empty-state CTAs are long gone, + and on desktop the rail is the only place left to start something. */ +.nav-action { + display: flex; + align-items: center; + gap: 0.625rem; + width: 100%; + border-radius: 0.75rem; + padding: 0.5rem 0.625rem; + font-size: 13.5px; + font-weight: 600; + color: var(--text); + background: var(--surface-strong); + border: 1px solid var(--glass-border); + transition: background-color 160ms ease, border-color 160ms ease; +} +.nav-action:hover { + background: var(--hover); + border-color: var(--nav-active-border); +} .nav-row[aria-current="page"], .nav-row[aria-pressed="true"] { background: var(--nav-active-bg); diff --git a/app/manifest.ts b/app/manifest.ts index 205c105..26d61e4 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -55,7 +55,7 @@ export default function manifest(): MetadataRoute.Manifest { ], shortcuts: [ { name: "New deck", short_name: "New", url: "/create" }, - { name: "Make my homework", short_name: "Homework", url: "/homework" }, + { name: "New homework", short_name: "Homework", url: "/homework" }, { name: "Library", short_name: "Library", url: "/" }, ], /* Share text and links from other apps (GET). Files are POSTed to the diff --git a/components/DataSection.tsx b/components/DataSection.tsx index bc33178..1bf771d 100644 --- a/components/DataSection.tsx +++ b/components/DataSection.tsx @@ -78,6 +78,8 @@ export function DataSection() { void pick(event.target.files?.[0])} diff --git a/components/DeckCard.tsx b/components/DeckCard.tsx index e42fce4..3a8621a 100644 --- a/components/DeckCard.tsx +++ b/components/DeckCard.tsx @@ -182,12 +182,12 @@ export function DeckCard({ ) : ( -

{truncate(deck.title, 90)} -

+ )} {/* Phones: one compact meta line. */}

diff --git a/components/HomeworkCard.tsx b/components/HomeworkCard.tsx index 43019e7..c085181 100644 --- a/components/HomeworkCard.tsx +++ b/components/HomeworkCard.tsx @@ -62,12 +62,12 @@ export function HomeworkCard({

-

{truncate(record.title, 90)} -

+

{record.mode === "make" ? : } diff --git a/components/LearningSection.tsx b/components/LearningSection.tsx index ebe20ef..0043eaf 100644 --- a/components/LearningSection.tsx +++ b/components/LearningSection.tsx @@ -25,7 +25,12 @@ function urlBase64ToUint8Array(base64: string): Uint8Array { * the server has VAPID keys configured. Without them the reminder block is * hidden completely, exactly as if the feature did not exist. */ -export function LearningSection() { +/** + * `level` keeps the heading order right in both places it is used: h2 on the + * library page (under its h1) and h3 inside the settings dialog (under its h2 + * title). + */ +export function LearningSection({ level = 2 }: { level?: 2 | 3 } = {}) { const { t } = useI18n(); const dailyGoal = useSyncExternalStore( settingsStore.subscribe, @@ -154,7 +159,11 @@ export function LearningSection() { return (

-

{t("settings.learning")}

+ {level === 3 ? ( +

{t("settings.learning")}

+ ) : ( +

{t("settings.learning")}

+ )}

{t("settings.dailyGoal")}

diff --git a/components/LibraryScreen.tsx b/components/LibraryScreen.tsx index ac7cc77..4e1bba6 100644 --- a/components/LibraryScreen.tsx +++ b/components/LibraryScreen.tsx @@ -220,6 +220,8 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | { diff --git a/components/ReportQuestionButton.tsx b/components/ReportQuestionButton.tsx index b26686a..a2cef9c 100644 --- a/components/ReportQuestionButton.tsx +++ b/components/ReportQuestionButton.tsx @@ -3,22 +3,31 @@ import { haptic } from "@/lib/haptics"; import { useI18n } from "@/lib/i18n"; import { reportQuestion, unreportQuestion } from "@/lib/question-reports"; -import type { Activity, Deck } from "@/lib/schemas"; +import type { Activity } from "@/lib/schemas"; import { showToast } from "@/lib/toast"; /** * The red "!" report control that sits under the card (bottom-right of the - * exercise): records the question, sends the operator a copy and skips past - * it. Undo lives in the toast. 44px, because it is a thumb target. + * exercise): records the question, sends the operator a copy and — in the + * study feeds — skips past it. Undo lives in the toast. 44px, because it is a + * thumb target. + * + * `deckId` is a deck id in the feeds and the homework record's id for a + * homework exercise; `onSkip` is omitted where there is no queue to skip. */ export function ReportQuestionButton({ - deck, + deckId, + deckTitle, activity, onSkip, + labelKey = "feed.report", }: { - deck: Deck; + deckId: string; + deckTitle?: string; activity: Activity; - onSkip: () => void; + onSkip?: () => void; + /** i18n key for the accessible name ("Report question" / "Report exercise"). */ + labelKey?: string; }) { const { t } = useI18n(); @@ -27,16 +36,16 @@ export function ReportQuestionButton({ type="button" onClick={() => { haptic("tap"); - reportQuestion({ deck, activity }); + reportQuestion({ deckId, deckTitle, activity }); showToast({ message: t("feed.reported"), actionLabel: t("toast.undo"), - onAction: () => unreportQuestion(deck.id, activity.id), + onAction: () => unreportQuestion(deckId, activity.id), }); - onSkip(); + onSkip?.(); }} - aria-label={t("feed.report")} - title={t("feed.report")} + aria-label={t(labelKey)} + title={t(labelKey)} className="grid size-11 shrink-0 place-items-center rounded-xl border-2 border-rose-400/80 text-base font-black leading-none text-rose-600 transition hover:bg-rose-50 dark:border-rose-400/50 dark:text-rose-300 dark:hover:bg-rose-400/10" > ! diff --git a/components/ReviewClient.tsx b/components/ReviewClient.tsx index aabd2e6..dfb4598 100644 --- a/components/ReviewClient.tsx +++ b/components/ReviewClient.tsx @@ -81,36 +81,39 @@ export function ReviewClient({ mistakes, deckId }: { mistakes: boolean; deckId?: const sessionItems: SessionItem[] = session.map(({ deck, activity }) => ({ deck, activity })); return ( - router.push("/")} - onAnswered={(item, correct) => { - haptic(correct ? "success" : "error"); - recordAnswer({ - deckId: item.deck.id, - deckTitle: item.deck.title, - activityId: item.activity.id, - type: item.activity.type, - topic: item.activity.topic, - correct, - }); - }} - renderResults={({ correct, total, score }) => ( -
-

{title}

-

{score}%

-

- {t("results.gotOf", { correct, total, retry: "" })} -

-
- + // The feed is a fixed overlay, but its content still needs a landmark. +
+ router.push("/")} + onAnswered={(item, correct) => { + haptic(correct ? "success" : "error"); + recordAnswer({ + deckId: item.deck.id, + deckTitle: item.deck.title, + activityId: item.activity.id, + type: item.activity.type, + topic: item.activity.topic, + correct, + }); + }} + renderResults={({ correct, total, score }) => ( +
+

{title}

+

{score}%

+

+ {t("results.gotOf", { correct, total, retry: "" })} +

+
+ +
-
- )} - /> + )} + /> + ); } diff --git a/components/SessionFeed.tsx b/components/SessionFeed.tsx index 78b5cf8..40dc1f5 100644 --- a/components/SessionFeed.tsx +++ b/components/SessionFeed.tsx @@ -221,7 +221,8 @@ export function SessionFeed({
{ setSkipped((current) => [...current, item.activity.id]); diff --git a/components/SettingsDialog.tsx b/components/SettingsDialog.tsx index 3096328..8db0245 100644 --- a/components/SettingsDialog.tsx +++ b/components/SettingsDialog.tsx @@ -407,7 +407,7 @@ export function SettingsDialog({
{/* ------------------------------------------- data & sync */} - + @@ -746,7 +746,7 @@ export function SettingsDialog({ {/* ------------------------------------------- data & sync */} - + diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 0950082..bba0f29 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -137,23 +137,24 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) { {!collapsed ? {t("sidebar.collapse")} : null} - {/* new deck: a quiet row, not a competing primary button ---------- */} + {/* new deck: a button, not a nav row — it stays visible when the + library is already full (see .nav-action in globals.css) --------- */} - + {!collapsed ? {t("common.newDeck")} : null} - {/* make my homework: same quiet row --------------------------------- */} + {/* new homework: the same button ------------------------------------ */} - + {!collapsed ? {t("homework.nav")} : null} diff --git a/components/StudyFeed.tsx b/components/StudyFeed.tsx index b86aa45..b9d8a6d 100644 --- a/components/StudyFeed.tsx +++ b/components/StudyFeed.tsx @@ -332,7 +332,8 @@ export function StudyFeed({ deck }: { deck: Deck }) {
{ setSkipped((current) => [...current, activity.id]); diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx index 4558cae..7dca7bb 100644 --- a/components/UploadScreen.tsx +++ b/components/UploadScreen.tsx @@ -650,6 +650,8 @@ export function UploadScreen() { { diff --git a/components/homework/HomeworkGuide.tsx b/components/homework/HomeworkGuide.tsx index a7af976..0ad75ef 100644 --- a/components/homework/HomeworkGuide.tsx +++ b/components/homework/HomeworkGuide.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityRenderer } from "@/components/activities"; import { PromptCard, QuestionText } from "@/components/activities/shared"; import { AiDisclaimer } from "@/components/AiDisclaimer"; +import { ReportQuestionButton } from "@/components/ReportQuestionButton"; import { XIcon } from "@/components/icons"; import { Button, ProgressBar } from "@/components/ui"; import { AskButton } from "@/components/chat/AskButton"; @@ -246,7 +247,7 @@ export function HomeworkGuide({ {/* checkpoint or a plain next button */} {step.checkpoint ? ( -
+

{t("homework.guide.tryIt")}

@@ -263,6 +264,16 @@ export function HomeworkGuide({ }} onNext={() => goTo(stepIndex + 1)} /> + {/* Same report control as the study feeds, under the + exercise (the homework record is the "deck" here). */} +
+ +
) : (