Compare commits
2 commits
aac6befb0d
...
df34337b4a
| Author | SHA1 | Date | |
|---|---|---|---|
| df34337b4a | |||
| 8822baf4b7 |
45 changed files with 863 additions and 94 deletions
44
README.md
44
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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,10 @@ export default function DeckPage() {
|
|||
|
||||
return (
|
||||
<ApiKeyGate>
|
||||
<StudyFeed key={deck.id} deck={deck} />
|
||||
{/* The feed is a fixed overlay, but its content still needs a landmark. */}
|
||||
<main>
|
||||
<StudyFeed key={deck.id} deck={deck} />
|
||||
</main>
|
||||
</ApiKeyGate>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ export function DataSection() {
|
|||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={(event) => void pick(event.target.files?.[0])}
|
||||
|
|
|
|||
|
|
@ -182,12 +182,12 @@ export function DeckCard({
|
|||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<h3
|
||||
<h2
|
||||
className="line-clamp-2 text-[15px] font-semibold leading-snug tracking-[-0.01em] text-[var(--text)]"
|
||||
title={deck.title}
|
||||
>
|
||||
{truncate(deck.title, 90)}
|
||||
</h3>
|
||||
</h2>
|
||||
)}
|
||||
{/* Phones: one compact meta line. */}
|
||||
<p className="mt-1 text-[12.5px] text-[var(--text-subtle)] sm:hidden">
|
||||
|
|
|
|||
|
|
@ -62,12 +62,12 @@ export function HomeworkCard({
|
|||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3
|
||||
<h2
|
||||
className="line-clamp-2 text-[15px] font-semibold leading-snug tracking-[-0.01em] text-[var(--text)]"
|
||||
title={record.title}
|
||||
>
|
||||
{truncate(record.title, 90)}
|
||||
</h3>
|
||||
</h2>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[12.5px] text-[var(--text-subtle)]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{record.mode === "make" ? <BoltIcon className="size-3.5" /> : <PlayIcon className="size-3.5" />}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<section className="mb-4 space-y-3 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] p-3">
|
||||
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">{t("settings.learning")}</h3>
|
||||
{level === 3 ? (
|
||||
<h3 className="text-sm font-semibold text-frost-950 dark:text-frost-50">{t("settings.learning")}</h3>
|
||||
) : (
|
||||
<h2 className="text-sm font-semibold text-frost-950 dark:text-frost-50">{t("settings.learning")}</h2>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[12px] font-semibold text-[var(--text)]">{t("settings.dailyGoal")}</p>
|
||||
|
|
|
|||
|
|
@ -220,6 +220,8 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
<input
|
||||
ref={importRef}
|
||||
type="file"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
accept="application/json,.json"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
<span aria-hidden>!</span>
|
||||
|
|
|
|||
|
|
@ -81,36 +81,39 @@ export function ReviewClient({ mistakes, deckId }: { mistakes: boolean; deckId?:
|
|||
const sessionItems: SessionItem[] = session.map(({ deck, activity }) => ({ deck, activity }));
|
||||
|
||||
return (
|
||||
<SessionFeed
|
||||
title={title}
|
||||
items={sessionItems}
|
||||
testId="review-feed"
|
||||
onExit={() => 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 }) => (
|
||||
<div className="card space-y-4 p-5 text-center">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">{title}</p>
|
||||
<p className="text-[42px] font-extrabold leading-none text-[var(--text)]">{score}%</p>
|
||||
<p className="text-sm font-medium text-[var(--text-muted)]">
|
||||
{t("results.gotOf", { correct, total, retry: "" })}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button size="lg" className="touch-target" onClick={() => router.push("/")}>
|
||||
{t("review.done")}
|
||||
</Button>
|
||||
// The feed is a fixed overlay, but its content still needs a landmark.
|
||||
<main>
|
||||
<SessionFeed
|
||||
title={title}
|
||||
items={sessionItems}
|
||||
testId="review-feed"
|
||||
onExit={() => 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 }) => (
|
||||
<div className="card space-y-4 p-5 text-center">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">{title}</p>
|
||||
<p className="text-[42px] font-extrabold leading-none text-[var(--text)]">{score}%</p>
|
||||
<p className="text-sm font-medium text-[var(--text-muted)]">
|
||||
{t("results.gotOf", { correct, total, retry: "" })}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button size="lg" className="touch-target" onClick={() => router.push("/")}>
|
||||
{t("review.done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -221,7 +221,8 @@ export function SessionFeed({
|
|||
<AiDisclaimer />
|
||||
<div className="flex justify-end">
|
||||
<ReportQuestionButton
|
||||
deck={item.deck}
|
||||
deckId={item.deck.id}
|
||||
deckTitle={item.deck.title}
|
||||
activity={item.activity}
|
||||
onSkip={() => {
|
||||
setSkipped((current) => [...current, item.activity.id]);
|
||||
|
|
|
|||
|
|
@ -407,7 +407,7 @@ export function SettingsDialog({
|
|||
</section>
|
||||
|
||||
{/* ------------------------------------------- data & sync */}
|
||||
<LearningSection />
|
||||
<LearningSection level={3} />
|
||||
<DataSection />
|
||||
<SyncSection />
|
||||
|
||||
|
|
@ -746,7 +746,7 @@ export function SettingsDialog({
|
|||
</section>
|
||||
|
||||
{/* ------------------------------------------- data & sync */}
|
||||
<LearningSection />
|
||||
<LearningSection level={3} />
|
||||
<DataSection />
|
||||
<SyncSection />
|
||||
|
||||
|
|
|
|||
|
|
@ -137,23 +137,24 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) {
|
|||
{!collapsed ? <span className="flex-1 truncate text-left">{t("sidebar.collapse")}</span> : null}
|
||||
</button>
|
||||
|
||||
{/* 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) --------- */}
|
||||
<Link
|
||||
href="/create"
|
||||
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
|
||||
className={cn("nav-action mb-2.5", collapsed && "justify-center px-2")}
|
||||
title={t("common.newDeck")}
|
||||
>
|
||||
<PlusIcon className="size-4 shrink-0" />
|
||||
<PlusIcon className="size-4 shrink-0 text-accent-600 dark:text-accent-200" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">{t("common.newDeck")}</span> : null}
|
||||
</Link>
|
||||
|
||||
{/* make my homework: same quiet row --------------------------------- */}
|
||||
{/* new homework: the same button ------------------------------------ */}
|
||||
<Link
|
||||
href="/homework"
|
||||
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
|
||||
className={cn("nav-action mb-3", collapsed && "justify-center px-2")}
|
||||
title={t("homework.nav")}
|
||||
>
|
||||
<SparklesIcon className="size-4 shrink-0" />
|
||||
<SparklesIcon className="size-4 shrink-0 text-accent-600 dark:text-accent-200" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">{t("homework.nav")}</span> : null}
|
||||
</Link>
|
||||
|
||||
|
|
|
|||
|
|
@ -332,7 +332,8 @@ export function StudyFeed({ deck }: { deck: Deck }) {
|
|||
<AiDisclaimer />
|
||||
<div className="flex justify-end">
|
||||
<ReportQuestionButton
|
||||
deck={currentDeck}
|
||||
deckId={currentDeck.id}
|
||||
deckTitle={currentDeck.title}
|
||||
activity={activity}
|
||||
onSkip={() => {
|
||||
setSkipped((current) => [...current, activity.id]);
|
||||
|
|
|
|||
|
|
@ -650,6 +650,8 @@ export function UploadScreen() {
|
|||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
|
||||
multiple
|
||||
className="sr-only"
|
||||
|
|
@ -663,6 +665,8 @@ export function UploadScreen() {
|
|||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="sr-only"
|
||||
|
|
@ -1128,6 +1132,8 @@ export function UploadScreen() {
|
|||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
|
||||
multiple
|
||||
className="sr-only"
|
||||
|
|
@ -1139,6 +1145,8 @@ export function UploadScreen() {
|
|||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="sr-only"
|
||||
|
|
|
|||
|
|
@ -105,6 +105,8 @@ export function Composer({
|
|||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
accept="image/*,application/pdf,.docx,.pptx,.odt,.txt,.md,.csv"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
||||
{t("homework.guide.tryIt")}
|
||||
</p>
|
||||
|
|
@ -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). */}
|
||||
<div className="flex justify-end">
|
||||
<ReportQuestionButton
|
||||
deckId={record.id}
|
||||
deckTitle={record.title}
|
||||
activity={step.checkpoint}
|
||||
labelKey="homework.report"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button className="touch-target self-start" onClick={() => goTo(stepIndex + 1)}>
|
||||
|
|
|
|||
|
|
@ -246,6 +246,8 @@ export function HomeworkInput({
|
|||
<input
|
||||
ref={solutionFileRef}
|
||||
type="file"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
|
||||
multiple
|
||||
className="sr-only"
|
||||
|
|
@ -257,6 +259,8 @@ export function HomeworkInput({
|
|||
<input
|
||||
ref={solutionCameraRef}
|
||||
type="file"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="sr-only"
|
||||
|
|
@ -378,6 +382,8 @@ export function HomeworkInput({
|
|||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
|
||||
multiple
|
||||
className="sr-only"
|
||||
|
|
@ -391,6 +397,8 @@ export function HomeworkInput({
|
|||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="sr-only"
|
||||
|
|
@ -756,6 +764,8 @@ export function HomeworkInput({
|
|||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
|
||||
multiple
|
||||
className="sr-only"
|
||||
|
|
@ -767,6 +777,8 @@ export function HomeworkInput({
|
|||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="sr-only"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -270,7 +270,7 @@ const en: Record<string, string> = {
|
|||
"health.createKey.end": "instead and set",
|
||||
|
||||
/* make my homework ------------------------------------------------- */
|
||||
"homework.nav": "Make my homework",
|
||||
"homework.nav": "New homework",
|
||||
"wizard.mode.deck": "Study deck",
|
||||
"wizard.mode.homework": "Make my homework",
|
||||
"wizard.mode.aria": "Study deck or homework",
|
||||
|
|
@ -317,6 +317,7 @@ const en: Record<string, string> = {
|
|||
"homework.guide.stepOf": "Step {n} of {total}",
|
||||
"homework.guide.close": "Close the guide",
|
||||
"homework.guide.tryIt": "Try it yourself",
|
||||
"homework.report": "Report exercise",
|
||||
"homework.guide.hint": "Show a hint",
|
||||
"homework.guide.hintMore": "Another hint",
|
||||
"homework.guide.hintAnswer": "Show the answer",
|
||||
|
|
@ -344,7 +345,7 @@ const en: Record<string, string> = {
|
|||
|
||||
/* quick actions (library empty state + /create switcher) ----------- */
|
||||
"emptyStateCreateDeck": "Create study deck",
|
||||
"emptyStateHomework": "Make my homework",
|
||||
"emptyStateHomework": "New homework",
|
||||
|
||||
/* library --------------------------------------------------------- */
|
||||
"library.title": "Your icebox",
|
||||
|
|
@ -742,6 +743,9 @@ const en: Record<string, string> = {
|
|||
"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":
|
||||
|
|
@ -1209,7 +1213,7 @@ const cs: Record<string, string> = {
|
|||
"health.createKey.end": "a nastavit",
|
||||
|
||||
/* make my homework ------------------------------------------------- */
|
||||
"homework.nav": "Udělej mi úkol",
|
||||
"homework.nav": "Nový úkol",
|
||||
"wizard.mode.deck": "Studijní sada",
|
||||
"wizard.mode.homework": "Udělej mi úkol",
|
||||
"wizard.mode.aria": "Studijní sada nebo úkol",
|
||||
|
|
@ -1256,6 +1260,7 @@ const cs: Record<string, string> = {
|
|||
"homework.guide.stepOf": "Krok {n}/{total}",
|
||||
"homework.guide.close": "Zavřít návod",
|
||||
"homework.guide.tryIt": "Zkus si to",
|
||||
"homework.report": "Nahlásit cvičení",
|
||||
"homework.guide.hint": "Ukaž nápovědu",
|
||||
"homework.guide.hintMore": "Další nápověda",
|
||||
"homework.guide.hintAnswer": "Ukaž odpověď",
|
||||
|
|
@ -1283,7 +1288,7 @@ const cs: Record<string, string> = {
|
|||
|
||||
/* quick actions (library empty state + /create switcher) ----------- */
|
||||
"emptyStateCreateDeck": "Vytvořit studijní balíček",
|
||||
"emptyStateHomework": "Udělej mi úkol",
|
||||
"emptyStateHomework": "Nový úkol",
|
||||
|
||||
/* library --------------------------------------------------------- */
|
||||
"library.title": "Tvoje mraznička",
|
||||
|
|
@ -1681,6 +1686,9 @@ const cs: Record<string, string> = {
|
|||
"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":
|
||||
|
|
|
|||
60
lib/model-guard.ts
Normal file
60
lib/model-guard.ts
Normal file
|
|
@ -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" } },
|
||||
);
|
||||
}
|
||||
|
|
@ -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<number> {
|
||||
/** 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<number> {
|
||||
|
|
|
|||
|
|
@ -132,10 +132,15 @@ export function withoutReported(deckId: string, activities: Activity[]): Activit
|
|||
}
|
||||
|
||||
/** Adds (or refreshes) a report and sends a copy to the server, best effort. */
|
||||
export function reportQuestion(input: { deck: { id: string; title: string }; activity: Activity }): QuestionReport {
|
||||
export function reportQuestion(input: {
|
||||
/** A deck id, or the homework record's id for a homework exercise. */
|
||||
deckId: string;
|
||||
deckTitle?: string;
|
||||
activity: Activity;
|
||||
}): QuestionReport {
|
||||
const entry: QuestionReport = {
|
||||
deckId: input.deck.id,
|
||||
deckTitle: input.deck.title,
|
||||
deckId: input.deckId,
|
||||
deckTitle: input.deckTitle,
|
||||
activityId: input.activity.id,
|
||||
type: input.activity.type,
|
||||
question: activityText(input.activity).slice(0, 600),
|
||||
|
|
|
|||
|
|
@ -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<string, number> };
|
||||
const slots: Map<string, number> = (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") ??
|
||||
|
|
|
|||
|
|
@ -8,6 +8,19 @@ import { z } from "zod";
|
|||
export const difficultySchema = z.enum(["easy", "medium", "hard"]);
|
||||
export type Difficulty = z.infer<typeof difficultySchema>;
|
||||
|
||||
/**
|
||||
* 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(),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "freezmo",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.6",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*
|
||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||
*/
|
||||
const CACHE_VERSION = "freezmo-v50";
|
||||
const CACHE_VERSION = "freezmo-v52";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
|
|
|
|||
169
tests/contrast.mjs
Normal file
169
tests/contrast.mjs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* Contrast diagnostic (NOT part of `npm run test:e2e`): axe cannot judge text
|
||||
* on a blurred glass surface — it reports those nodes as "incomplete" — so
|
||||
* this samples the **rendered pixels** instead.
|
||||
*
|
||||
* For every text element it takes a screenshot of that element's box, finds
|
||||
* the dominant colour in it (the background: glyphs are the minority of the
|
||||
* pixels) and compares it with the text colour from the computed style using
|
||||
* the WCAG relative-luminance formula. Large text (>= 24px, or >= 18.66px
|
||||
* bold) is held to 3:1, everything else to 4.5:1.
|
||||
*
|
||||
* node tests/contrast.mjs # every screen, both themes
|
||||
* node tests/contrast.mjs study # filter by screen name
|
||||
*
|
||||
* It always exits 0 — the numbers are for tuning the palette.
|
||||
*/
|
||||
import { BASE, DECK, launchBrowser, makePage, seed } from "./lib.mjs";
|
||||
|
||||
const filter = process.argv[2] ?? "";
|
||||
const browser = await launchBrowser();
|
||||
const failures = [];
|
||||
|
||||
/** WCAG relative luminance of an [r, g, b] triple (0-255). */
|
||||
const LUMINANCE = `
|
||||
const luminance = ([r, g, b]) => {
|
||||
const channel = (value) => {
|
||||
const v = value / 255;
|
||||
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
||||
};
|
||||
const contrast = (a, b) => {
|
||||
const [l1, l2] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (l1 + 0.05) / (l2 + 0.05);
|
||||
};
|
||||
`;
|
||||
|
||||
const screens = [
|
||||
["library", "/", null],
|
||||
["study", "/deck/seed_deck", null],
|
||||
["create", "/create", null],
|
||||
["homework", "/homework", null],
|
||||
["chat", "/chat", null],
|
||||
["stats", "/stats", null],
|
||||
["exam", "/exam", null],
|
||||
["settings", "/", "settings"],
|
||||
];
|
||||
|
||||
for (const [label, path, mode] of screens) {
|
||||
if (filter && !label.includes(filter)) continue;
|
||||
for (const theme of ["dark", "light"]) {
|
||||
const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1 });
|
||||
await seed(page, { decks: [DECK], theme });
|
||||
await page.goto(`${BASE}${path}`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(1000);
|
||||
if (mode === "settings") {
|
||||
await page.locator("nav.tabbar").getByRole("button").click();
|
||||
await page.getByRole("dialog").waitFor({ timeout: 6000 });
|
||||
await page.waitForTimeout(700);
|
||||
}
|
||||
|
||||
// Every text-bearing element with a visible box, small enough to sample.
|
||||
// Content behind an open sheet is dimmed on purpose and not readable, and
|
||||
// disabled controls are exempt from 1.4.3 — both are skipped.
|
||||
const targets = await page.evaluate(() => {
|
||||
const out = [];
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
const root = dialog ?? document;
|
||||
for (const element of root.querySelectorAll("p, span, li, h1, h2, h3, button, a, label, dt, dd")) {
|
||||
if (element.closest("[disabled]") || element.closest('[aria-disabled="true"]')) continue;
|
||||
const text = (element.textContent ?? "").trim();
|
||||
if (text.length < 2) continue;
|
||||
if (element.children.length > 0 && [...element.children].some((child) => (child.textContent ?? "").trim().length > 0)) continue;
|
||||
const style = getComputedStyle(element);
|
||||
if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity) < 0.5) continue;
|
||||
const box = element.getBoundingClientRect();
|
||||
if (box.width < 8 || box.height < 8 || box.bottom < 0 || box.top > window.innerHeight) continue;
|
||||
out.push({
|
||||
id: out.length,
|
||||
text: text.slice(0, 40),
|
||||
color: style.color,
|
||||
fontSize: Number.parseFloat(style.fontSize),
|
||||
fontWeight: Number(style.fontWeight),
|
||||
box: { x: box.x, y: box.y, width: box.width, height: box.height },
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const screenshot = await page.screenshot({ type: "png", scale: "css" });
|
||||
const dataUrl = `data:image/png;base64,${screenshot.toString("base64")}`;
|
||||
const report = await page.evaluate(
|
||||
async ({ targets, dataUrl, luminanceSource }) => {
|
||||
const image = await new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = dataUrl;
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const full = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
const { contrast } = new Function(`${luminanceSource} return { contrast };`)();
|
||||
const parseColor = (value) => {
|
||||
const match = /rgba?\(([^)]+)\)/.exec(value);
|
||||
if (!match) return null;
|
||||
const parts = match[1].split(",").map((part) => Number.parseFloat(part));
|
||||
return [parts[0], parts[1], parts[2], parts[3] ?? 1];
|
||||
};
|
||||
const results = [];
|
||||
for (const target of targets) {
|
||||
const text = parseColor(target.color);
|
||||
if (!text) continue;
|
||||
// Sample a slightly larger box and ignore pixels close to the text
|
||||
// colour: those are the glyphs themselves, not the background.
|
||||
const x0 = Math.max(0, Math.floor(target.box.x) - 2);
|
||||
const y0 = Math.max(0, Math.floor(target.box.y) - 2);
|
||||
const x1 = Math.min(canvas.width, Math.ceil(target.box.x + target.box.width) + 2);
|
||||
const y1 = Math.min(canvas.height, Math.ceil(target.box.y + target.box.height) + 2);
|
||||
if (x1 - x0 < 4 || y1 - y0 < 4) continue;
|
||||
const counts = new Map();
|
||||
let sampled = 0;
|
||||
for (let y = y0; y < y1; y += 1) {
|
||||
for (let x = x0; x < x1; x += 1) {
|
||||
const index = (y * canvas.width + x) * 4;
|
||||
const pixel = [full[index], full[index + 1], full[index + 2]];
|
||||
const distance = Math.max(
|
||||
Math.abs(pixel[0] - text[0]),
|
||||
Math.abs(pixel[1] - text[1]),
|
||||
Math.abs(pixel[2] - text[2]),
|
||||
);
|
||||
if (distance <= 24) continue; // glyph pixel (or its antialiasing)
|
||||
const key = `${pixel[0]},${pixel[1]},${pixel[2]}`;
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
sampled += 1;
|
||||
}
|
||||
}
|
||||
if (sampled < 12) continue; // all glyphs: nothing to measure against
|
||||
let best = null;
|
||||
for (const [key, count] of counts) if (!best || count > best.count) best = { key, count };
|
||||
const background = best.key.split(",").map(Number);
|
||||
const ratio = contrast(text, background);
|
||||
const large = target.fontSize >= 24 || (target.fontSize >= 18.66 && Number(target.fontWeight) >= 700);
|
||||
const minimum = large ? 3 : 4.5;
|
||||
if (ratio < minimum) {
|
||||
results.push({ text: target.text, ratio: Number(ratio.toFixed(2)), minimum, color: target.color, background: best.key });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
{ targets, dataUrl, luminanceSource: LUMINANCE },
|
||||
);
|
||||
|
||||
if (report.length > 0) {
|
||||
console.log(`\n[${theme}] ${label} — ${report.length} below minimum`);
|
||||
for (const row of report) {
|
||||
console.log(` ${row.ratio}:1 (needs ${row.minimum}) "${row.text}" — ${row.color} on rgb(${row.background})`);
|
||||
failures.push({ theme, label, ...row });
|
||||
}
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\ncontrast scan done — ${failures.length} below-minimum findings`);
|
||||
await browser.close();
|
||||
|
|
@ -74,7 +74,7 @@ const dragDown = async (page, locator, dy = 170, steps = 9) => {
|
|||
check("CTA >= 44px tall", ctaBox.height >= 43.5, String(Math.round(ctaBox.height)));
|
||||
await page.getByRole("link", { name: "Homework", exact: true }).click();
|
||||
await page.waitForTimeout(700);
|
||||
const hwCta = page.getByRole("link", { name: "Make my homework", exact: true });
|
||||
const hwCta = page.getByRole("link", { name: "New homework", exact: true });
|
||||
check("homework quick action", await hwCta.isVisible());
|
||||
await page.getByRole("link", { name: "Study Decks", exact: true }).click();
|
||||
await page.waitForTimeout(700);
|
||||
|
|
@ -174,7 +174,7 @@ for (const locale of ["en", "cs"]) {
|
|||
check("scrolled: drag snaps back, sheet stays", (await dialog.count()) === 1);
|
||||
await page.locator(".sheet-scroll").evaluate((el) => (el.scrollTop = 0));
|
||||
await page.waitForTimeout(250);
|
||||
await dragDown(page, dialog.locator("h2"));
|
||||
await dragDown(page, dialog.locator("#settings-title"));
|
||||
await page.waitForTimeout(800);
|
||||
check("handle+header swipe closes the sheet", (await dialog.count()) === 0);
|
||||
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ const browser = await launchBrowser();
|
|||
await page.goto(target, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(600);
|
||||
check("share: chooser lists the file", await page.getByText("shared.txt").isVisible());
|
||||
await page.getByRole("button", { name: "Make my homework" }).first().click();
|
||||
await page.getByRole("button", { name: "New homework" }).first().click();
|
||||
await page.waitForTimeout(1200);
|
||||
check("share: homework got the file", await page.getByText("shared.txt").first().isVisible());
|
||||
await context.close();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const browser = await launchBrowser();
|
|||
|
||||
check("library title", await page.getByRole("heading", { name: "Your icebox" }).isVisible());
|
||||
const sidebar = page.getByRole("complementary", { name: "Sidebar" });
|
||||
for (const row of ["New deck", "Make my homework", "All decks", "Unorganized", "Biology", "Progress", "Exam mode", "Settings"]) {
|
||||
for (const row of ["New deck", "New homework", "All decks", "Unorganized", "Biology", "Progress", "Exam mode", "Settings"]) {
|
||||
check(`sidebar row: ${row}`, (await sidebar.getByText(row, { exact: true }).count()) >= 1);
|
||||
}
|
||||
check("desktop hides the tab bar", !(await page.locator("nav.tabbar").isVisible()));
|
||||
|
|
@ -74,7 +74,7 @@ const browser = await launchBrowser();
|
|||
|
||||
await page.getByRole("link", { name: "Homework", exact: true }).click();
|
||||
await page.waitForTimeout(600);
|
||||
check("homework tab empty state", await page.getByRole("link", { name: "Make my homework", exact: true }).isVisible());
|
||||
check("homework tab empty state", await page.getByRole("link", { name: "New homework", exact: true }).isVisible());
|
||||
await context.close();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const SUITES = [
|
|||
"phase1-test",
|
||||
"phase2-test",
|
||||
"phase3-test",
|
||||
"security-test",
|
||||
"chat-test",
|
||||
"review-test",
|
||||
];
|
||||
|
|
|
|||
208
tests/security-test.mjs
Normal file
208
tests/security-test.mjs
Normal file
|
|
@ -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();
|
||||
|
|
@ -89,7 +89,7 @@ for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 }
|
|||
|
||||
await page.getByRole("link", { name: "Homework", exact: true }).click();
|
||||
await page.waitForTimeout(600);
|
||||
await page.getByRole("link", { name: "Make my homework", exact: true }).click();
|
||||
await page.getByRole("link", { name: "New homework", exact: true }).click();
|
||||
await sampleBar(page, "homework empty state → homework");
|
||||
check("homework headline is the step name", await page.getByRole("heading", { name: "The assignment" }).isVisible());
|
||||
check("homework Next disabled with nothing", await page.getByRole("button", { name: "Next" }).isDisabled());
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue