diff --git a/.env.example b/.env.example index d14516b..2104115 100644 --- a/.env.example +++ b/.env.example @@ -100,6 +100,20 @@ DATABASE_URL=postgresql://schulcloud:schulcloud@postgres:5432/schulcloud # from somewhere else and should have exactly one writer. # NOTES_READONLY=false +# Password for the web app at /app — the notes editor and the settings page +# where the Schulcloud token is replaced. Unset = the app is not served at all. +# +# Unlike every other credential here this one is typed by a person on a phone, +# so it is a passphrase rather than a random token: at least 12 characters, and +# three or four words is the right shape. It is hashed with scrypt at startup +# and the plain value is never stored, compared or logged. Changing it logs out +# every session, because the session signing key is derived from it. +# +# The app is on the internet like the rest of the endpoint. Failed logins are +# rate-limited per address, but the password is the thing protecting the notes +# and the Schulcloud token — pick a long one. +# WEB_PASSWORD= + # --------------------------------------------------------------------------- # WebUntis (optional — the timetable, which Schulcloud does not hold) # --------------------------------------------------------------------------- diff --git a/CLAUDE.md b/CLAUDE.md index 24a52ed..050035e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ have what the teacher stressed. An answer that silently merges them is worse than one that says which said what. Three entry points over one core: -- `src/bin/http.ts` — Streamable HTTP + `/api`, the deployed form, behind Caddy on a Pi. +- `src/bin/http.ts` — Streamable HTTP + `/api` + `/app`, the deployed form, behind Caddy on a Pi. - `src/bin/stdio.ts` — stdio, for local Claude Code / Desktop use. - `src/bin/cli.ts` — the `schulcloud` CLI, which talks to the HTTP server, never to Schulcloud. @@ -61,8 +61,8 @@ exercised and can never touch real notes. The degradation paths are supported modes, not fallbacks nobody exercises. The check counts are a tripwire, so re-measure them rather than trusting this -line after a change: **88/89 against the local instance** on 2026-09-18 (the one -failure is the H5P service, which that instance does not run). The live counts +line after a change: **106/107 against the local instance** on 2026-09-19 (the +one failure is the H5P service, which that instance does not run). The live counts are stale — they were last taken before the notes and class-register work, and could not be retaken because the live session had lapsed. Every Schulcloud check fails with 401 when the live session has lapsed — check the container's keepalive log before suspecting code. @@ -108,8 +108,17 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync from Profil → Freigaben, so there is no session and no keepalive on this side. Resolves the payload's element ids to names and returns every day in a range, empty ones included. See Invariants for why the allowlist is there. + - **`day-note.ts`** — a school day as a note: the timetable turned into one + `##` heading per lesson. `lessonHeading` and `notes.ts`'s + `subjectFromHeading` are a **loop** — the app writes the heading and the + indexer reads the subject back out of it, so a change to either without the + other files a day's notes under nothing. `test/day-note.test.ts` holds them + to it. - **`notes.ts`** — the user's own lesson notes as a directory of Markdown - files with a small frontmatter dialect. The files are the truth and the + files with a small frontmatter dialect. A note with `##` headings is a + school day and is indexed **per lesson**, not whole: indexed whole, every + hit would read "my note, Monday" and "what did we do in Deutsch" would match + a note whose other five lessons were something else. The files are the truth and the index is a view of them, so `list_notes`/`get_note` read disk and answer before the first crawl and while Postgres is down. **The one thing anything here writes** — see Invariants. `docs/NOTES.md` is the guide. @@ -130,6 +139,20 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync open `/api`. Besides those: the optional `//mcp` for clients without headers (`MCP_PATH_SECRET`), and `/token`, a page that PUTs a fresh token to `/api/token`. docs/AUTH.md and docs/DEPLOYMENT.md say why each exists. +- **`http/app-page.ts` + `http/app/`** — `/app`, the one surface meant for a + person rather than a program: the day's notes and a settings page for the + Schulcloud token. Served only when `WEB_PASSWORD` is set. Its assets are + **files** under `src/http/app/`, copied to `dist/` by `scripts/copy-assets.mjs` + and read relative to `import.meta.dirname` — real HTML, CSS and JS that an + editor and a linter understand, which is also what the CSP requires, since it + forbids inline script. +- **`http/web-auth.ts`** — the app's login, which is a different kind of + credential from everything else here: a password a person types, not a token a + program was configured with. scrypt at startup, a signed `HttpOnly` / + `SameSite=Strict` session cookie, per-address rate limiting. The session key + is **derived from the password**, so changing it logs every session out and + there is no second secret to store. The cookie opens `/api` — a session *is* + the user — and never `/mcp`. See Invariants. - **`store/`** — crawl generations, identity diffs, `german` + `pg_trgm` FTS. `Store.open` returns `undefined` when Postgres is down; callers degrade. - **`indexer/`** — crawl → persist → mirror bytes → extract text → index. @@ -178,6 +201,16 @@ neither the bearer nor the `jwt` cookie may go with it. `refresh_index` and `POS own index and mirror, and `PUT /api/token` only to the server's own token — every upstream call they make is still a GET. +**The app's session opens `/api`, never `/mcp`, and the app is not served +without a password.** `WEB_PASSWORD` is the only credential here a human types, +so it is the only one that can be guessed: the rate limiter in `web-auth.ts` is +not decoration, and the scrypt cost that makes guessing expensive is itself a +denial-of-service vector without it. The password is hashed at startup and never +stored, compared or logged in the clear — it is a secret by the rule below, and +so is the session cookie. Unset means the app does not exist, the same rule the +`untis_*` and note tools follow: a login screen no password can open is worse +than no page, because it looks like a way in. + **The notes directory is the only thing anything here writes to.** That is not an exception to the invariant above — it is a different store: the user's own files, never Schulcloud and never WebUntis. It is bounded by the same two @@ -210,7 +243,8 @@ the property that makes that acceptable. Do not add a write tool without the user explicitly asking for one and understanding this. **Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the -account; `MCP_AUTH_TOKEN` and `MCP_CONNECTOR_TOKEN` guard the endpoint; +account; `MCP_AUTH_TOKEN`, `MCP_CONNECTOR_TOKEN` and `WEB_PASSWORD` guard the +endpoint; `UNTIS_SECRET` authenticates as the user in WebUntis and outlives every other credential here, since it does not expire. None belongs in logs, error messages, or tool output. `.env` is git-ignored — keep it that way. @@ -462,8 +496,8 @@ bundle (2.1.272), not its docs: `.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`, and optionally `MCP_CONNECTOR_TOKEN` or `MCP_PATH_SECRET`, plus the four `UNTIS_*` values (all four or none — a half-filled block is a paste that went wrong, so it throws), -`NOTES_DIR` and `UNTIS_HISTORY_DAYS`; docker-compose sets `STATE_DIR` and -`NOTES_DIR`. See `.env.example` for the +`NOTES_DIR`, `UNTIS_HISTORY_DAYS` and `WEB_PASSWORD` (the app at `/app`); +docker-compose sets `STATE_DIR` and `NOTES_DIR`. See `.env.example` for the full set and `docs/AUTH.md` for refreshing the JWT — `schulcloud token set`, no restart. `npm run probe` and `schulcloud token` report the clocks: days until hard expiry and the session budget. diff --git a/README.md b/README.md index ed40df4..f1a05f3 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,21 @@ WebUntis has the schedule; neither has what the teacher actually stressed. Point all three prompts read it alongside everything else — including a migration path out of Apple Notes. See [docs/NOTES.md](docs/NOTES.md). +## The notes app + +A small web app at `/app`, for writing those notes: a login, the day's notes, +and a settings page for the Schulcloud token. Set `WEB_PASSWORD` to serve it. + +One note per school day, one `##` heading per lesson — and **the headings come +from WebUntis**, so opening a day gives you it already laid out with times, +teachers, rooms, cancellations dropped and substitutions marked. Each heading is +indexed as its own lesson, so a search answers "my own note, Deutsch, +18.09.2026" rather than "Friday". + +It saves as you type, keeps a local copy of every keystroke for when the signal +goes, and refuses a save that would overwrite a version it never saw. On a phone +it adds to the home screen and opens standalone. + **Three ready-made prompts**, in German because the school is: | | | @@ -205,7 +220,7 @@ src/ store/ Postgres: crawl generations, diffs, full-text search indexer/ crawl → persist → mirror bytes → extract text → index mcp/ MCP server, tools, resources and prompts - http/ express app, bearer auth, /api for the CLI + http/ express app, bearer auth, /api for the CLI, /app for people cli/ CLI config, API client, sync engine bin/ http, stdio and cli entry points docs/ API findings, auth, deployment, CLI, notes, roadmap diff --git a/deploy/Caddyfile.snippet b/deploy/Caddyfile.snippet index 6c3d1c3..2176ab0 100644 --- a/deploy/Caddyfile.snippet +++ b/deploy/Caddyfile.snippet @@ -14,7 +14,12 @@ mcp.example.org { # `schulcloud-mcp` is the Compose service name; Docker's embedded DNS # resolves it on the shared network. No host port is published. One proxy - # serves both surfaces: /mcp for Claude and /api for the CLI. + # serves all three surfaces: /mcp for Claude, /api for the CLI, and /app for + # the notes app in a browser. + # + # Caddy sets X-Forwarded-Proto on its own, and the app needs it: the hop to + # the container is plain HTTP, so that header is the only evidence the + # session cookie may be marked Secure. Do not strip it. reverse_proxy schulcloud-mcp:8080 { # MCP's Streamable HTTP transport keeps a server-sent-events channel # open for server-initiated messages. Without flush_interval -1 Caddy diff --git a/docs/AUTH.md b/docs/AUTH.md index c0b11ce..a92b674 100644 --- a/docs/AUTH.md +++ b/docs/AUTH.md @@ -251,15 +251,50 @@ access log entry is written. Rotating it means a new value, a recreated container, and re-adding the connector. Prefer the connector token wherever a header can be sent: a URL is copied into more places than a header is. +### The app password, for a person + +`WEB_PASSWORD` is unlike every other credential here: it is typed by a human, on +a phone, in a lesson. That single fact drives its whole design. + +- **It is a passphrase, not a token.** `config.ts` insists on 12 characters and + nothing else; demanding punctuation would buy little next to length, and the + failure mode of a fussy rule is a shorter password, not a better one. +- **It is never stored in the clear.** scrypt (N=16384) at startup; a login + hashes the attempt and compares in constant time. The error states the rule + and never echoes the value. +- **Logins are rate-limited per address**, eight failures in fifteen minutes. + Not optional: a password is guessable in a way a 32-byte token is not, and the + scrypt cost that makes guessing expensive is itself a denial-of-service vector + without a limiter in front of it. +- **The session is a signed cookie**, `HttpOnly` and `SameSite=Strict` — the + latter standing in for CSRF tokens, since nothing links into the app from + anywhere else. 30 days, because the alternative is a login screen at the start + of a lesson. +- **The signing key is derived from the password**, so changing it invalidates + every session that exists. No second secret, nothing to store, and the + behaviour anyone changing a password already expects. +- **The cookie opens `/api` and not `/mcp`.** A session *is* the user, and the + app is built on `/api` — but nothing in a browser speaks MCP, and a surface + that is not needed is not offered. + +Unset, the app is not served at all. A login screen that no password can open is +worse than no page, because it looks like a way in. + ## Blast radius Every path in this server is a `GET`, including the `api_get` escape hatch, which rejects anything not starting with `/api/` and anything carrying a scheme or host. Someone who obtained both the endpoint URL and `MCP_AUTH_TOKEN` — or -the connector token, or the secret MCP path — could read this account's -Schulcloud data; they could not +the connector token, the secret MCP path, or the app password — could read this +account's Schulcloud data; they could not post, submit, delete, or otherwise act as the user. With `MCP_AUTH_TOKEN` they could also call `PUT /api/token`, but it accepts only a live token for the same account, so the most it can do is hand the server a session the owner already has. Keep it that way — adding a single write tool would change that property entirely. + +The app password and `MCP_AUTH_TOKEN` additionally reach the notes: they can +read, write and overwrite files under `NOTES_DIR`, and nothing outside it — +`safeComponent` and `resolveWithin` are what make that a property rather than a +hope. That is the only write anywhere in this server, and it touches the user's +own files, never Schulcloud. `NOTES_READONLY=1` removes even that. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 5bb7433..32331ff 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -216,6 +216,32 @@ for a deployment: time-based code; a drifting clock is refused with "invalid client time", which the tools report in those words. +## The notes app + +Set `WEB_PASSWORD` in `.env` and the server offers `/app`: the notes editor and +a settings page. Unset, it is not served at all, and nothing else changes. + +``` +https://mcp.example.org/app/ +``` + +Three things worth knowing for a deployment: + +- **Recreate the container after changing the password** (`docker compose up -d + --force-recreate schulcloud-mcp`) — `env_file` is read at creation. Changing + it also logs out every session, by design: the session signing key is derived + from it. +- **Notes need somewhere to live.** `docker-compose.yml` sets + `NOTES_DIR=/data/notes` with a volume of its own. To write the notes from a + phone through a sync tool as well as through the app, bind-mount a real + directory there instead — see [NOTES.md](NOTES.md). +- **The app is a way to replace the Schulcloud token**, which is the next + section, and the more comfortable one when the expiry catches you away from a + terminal. + +`/token` still exists and still works. It is the fallback for a deployment with +no `WEB_PASSWORD`, and it is unchanged. + ## Replacing the Schulcloud token The token lasts 30 days at most and can only come from a browser login (see @@ -224,8 +250,9 @@ or an `.env` edit: 1. Log in to Schulcloud in a **private window** and copy the `jwt` cookie's value (DevTools → Application → Cookies). -2. Either run `schulcloud token set` and paste it, or open - `https://mcp.example.org/token` and paste it together with `MCP_AUTH_TOKEN`. +2. Either run `schulcloud token set` and paste it, open the app's + **Einstellungen** tab, or open `https://mcp.example.org/token` and paste it + together with `MCP_AUTH_TOKEN`. 3. **Close the private window.** The server checks the token with Schulcloud first — right account, not diff --git a/docs/NOTES.md b/docs/NOTES.md index b48153f..b9099e3 100644 --- a/docs/NOTES.md +++ b/docs/NOTES.md @@ -7,113 +7,142 @@ whatever you write down during the lesson, and until now it lived somewhere no agent could read. This is the third source: a directory of Markdown files the server reads, -indexes and searches alongside everything else. +indexes and searches alongside everything else, and a small web app to write +them in. + +## One note per school day ``` NOTES_DIR/ + 2026/ + 2026-09-18.md ← Freitag, one heading per lesson + 2026-09-21.md Deutsch/ - 2026-09-15 Erörterung.md - 2026-09-22 Sprachanalyse.md - LF07/ - 2026-09-16 Subnetting.md - Allgemein/ - 2026-09-18 Elternabend.md + 2026-09-15 Erörterung.md ← a single-subject note, e.g. from the import ``` -## Why files - -Three properties, in this order: - -- **They have to be writable from a classroom.** Whatever you take notes in on a - phone or a laptop, it can produce text files; nothing can produce rows in the - Pi's Postgres. -- **They have to be readable when the index is down.** `list_notes` and - `get_note` read the disk, so they answer before the first crawl and while - Postgres is unreachable — the index is a view of the notes, never the notes - themselves. That is the same split as extracted file text and the mirror. -- **They have to survive this project.** A directory of dated Markdown is - greppable, diffable, syncable and still yours if the server is thrown away. - -## What a note looks like - -Frontmatter, then the note. Every field is optional, and a plain `.md` file with -no frontmatter at all is a perfectly good note. +A day note looks like this, and the shape is load-bearing: ```markdown --- -title: Erörterung — Aufbau -date: 2026-09-15 -subject: Deutsch -tags: [klausur, aufsatz] +title: Freitag, 18.09.2026 +date: 2026-09-18 +source: notes-page --- +## 1. Deutsch — 08:00–08:45 · MEI · R 204 + Dreischritt: These, Argument mit Beleg, Fazit. -Frau Meier betont: das **Gegenargument** darf nicht fehlen, sonst gibt es -Abzug — kam letztes Jahr in der Arbeit dran. +### Aufbau + +- Gegenargument nicht vergessen — kam letztes Jahr in der Arbeit dran + +## 2. LF07 — 08:50–09:35 · Sb · R 108 + +| Präfix | Nutzbare Adressen | +|---|---| +| /24 | 254 | ``` -What the server reads out of it: +**Each `##` is indexed as its own lesson.** A search for *Gegenargument* answers +"my own note, Deutsch, 18.09.2026", not "my own note, Friday"; `list_notes +subject=Deutsch` finds this day even though the note's frontmatter names no +subject; and `what_changed` reports the lesson that changed rather than the +whole day. Prose, lists, tables and `###` subheadings all live inside their +lesson. -| Field | Falls back to | -|---|---| -| `title` | the first `# heading`, else the filename without its date | -| `date` | a `YYYY-MM-DD` the **filename** starts with, else nothing | -| `subject` | the first folder name (`Deutsch/…`) | -| `tags` | none | -| `courseId` | none — set it to tie a note to a Schulcloud course in `search` | +Nothing forces the shape. A note with no `##` headings — an imported Apple Note, +a page of revision — is indexed whole, as one piece of prose. -`date` also accepts `15.09.2026`, and `fach:` works as a German spelling of -`subject:`. Anything else in the block is kept but not interpreted. +## The app -**The file's modification time is never used as the date.** An import writes -every note today; dating a year of lessons "today" would make the whole store -useless for revision, which is most of what it is for. +`/app` is a small web app: a login, the day's notes, and a settings page. It is +served only when `WEB_PASSWORD` is set. -## Reading them +``` +https://mcp.example.org/app/ +``` -| Tool | Answers | -|---|---| -| `list_notes` | "what did I write down in Deutsch before the test" — filter by subject or date | -| `get_note` | one note in full, by the path everything else prints | -| `search` | notes by their contents, next to board text and the inside of PDFs | -| `what_changed` | notes that appeared or were edited since a date | +On a phone it is worth adding to the home screen — it has a manifest and opens +standalone, which is the difference between "a page I have to find" and "the +thing I open in a free period". -The `pruefungsvorbereitung`, `zusammenfassung` and `tagesvorbereitung` prompts -consult them on their own, and are told to say when a note disagrees with the -uploaded material rather than quietly preferring one. +**Notizen** is one screen: the day, `‹ ›` to move between days, and the editor. -## Writing them +- Opening a day with no note yet **fills in that day's lessons from WebUntis** — + numbered, with times, teacher and room, cancellations left out and + substitutions marked. That is the whole reason the app is day-shaped: the one + thing WebUntis knows and you should not have to retype. +- **Stunden ergänzen** appears when the timetable has a lesson your note does + not — a day you started writing before it ended, or a timetable that changed + after you started. It appends what is missing and never touches what you wrote. +- It **saves as you type** (a couple of seconds after you stop), when the app + goes to the background, and when the phone locks. +- Every keystroke also goes to the browser's local storage. If the connection + drops mid-lesson you keep writing, and the next time the app loads that day it + offers the version this device has. **A note taken in a lesson cannot be + retaken**, which is the reasoning behind all of this. +- If the note changed elsewhere since you opened it — the laptop, a sync tool, + `add_note` — the save is refused and you are asked which version wins. It + never silently overwrites. -Four ways in, all landing in the same files: +**Einstellungen** holds the Schulcloud token: how long it has left, and the box +to paste a fresh `jwt` cookie into when it expires (the same thing `schulcloud +token set` and the older `/token` page do). It also shows the index's state and +the notes directory, and has the logout button. + +### The login + +`WEB_PASSWORD` is the app's password — at least 12 characters, and a passphrase +of three or four words is the right shape. It is hashed with scrypt at startup; +the plain value is never stored, compared or logged. + +Logging in sets an `HttpOnly`, `SameSite=Strict` session cookie that lasts 30 +days, so a lesson never starts with a login screen. The cookie opens `/api` — +it *is* the user — but not `/mcp`, which no browser needs. Changing +`WEB_PASSWORD` invalidates every session, because the signing key is derived +from it. Failed logins are rate-limited per address; a password is guessable in +a way a 32-character token is not, and this endpoint is on the internet. + +If `WEB_PASSWORD` is unset the app is not served at all — the same rule the +`untis_*` and note tools follow. A login screen that no password can open is +worse than no page, because it looks like a way in. + +## The other four ways to write a note + +The app is not privileged; it writes the same files as everything else. ```bash -# from the command line, text piped in +# the command line, text piped in pbpaste | schulcloud note add --title "Subnetting" --subject LF07 -# from an editor, or anything else that writes files -$EDITOR "$NOTES_DIR/LF07/2026-09-16 Subnetting.md" +# an editor, or anything else that writes files +$EDITOR "$NOTES_DIR/2026/2026-09-18.md" -# by asking Claude, during or after the lesson +# Claude, during or after the lesson # "halt fest: Gegenargument nicht vergessen, kam letztes Jahr dran" # → add_note, subject Deutsch, today's date -# by syncing a folder you already write in — see below +# a folder you already sync — see below ``` `add_note` and `schulcloud note add` take `append`, which adds to the note -already written for that subject and day instead of starting a second one. That -is the right choice while a lesson is running: notes accumulate in one file the -way they do on paper. +already written **for that subject and that day** rather than starting a second +one — so "note this down too" mid-lesson lands in the note that is already open, +whatever its title. -**This is the only thing in this server that writes anything.** It writes into -the notes directory and nowhere else: every path component goes through -`safeComponent` and the result through `resolveWithin`, the same two functions -that stop a hostile filename escaping the file mirror, so a note titled -`../../.ssh/authorized_keys` becomes a filename. Schulcloud and WebUntis stay -strictly read-only — see the invariants in `CLAUDE.md`. Set `NOTES_READONLY=1` -to refuse writes entirely, which is right when the notes are synced in from -somewhere else and should have exactly one writer. +## What may write, and where + +**The notes directory is the only thing this server writes to.** Not Schulcloud, +not WebUntis — those stay read-only, strictly, and that has not changed. + +Every path component goes through `safeComponent` and the result through +`resolveWithin`, the same two functions that stop a hostile Schulcloud filename +escaping the file mirror, so a note titled `../../.ssh/authorized_keys` becomes +a filename. `NOTES_READONLY=1` refuses writes entirely — right when the notes +are synced in from somewhere else and should have exactly one writer. The app +then still reads them, and says so rather than failing on save. ## Migrating out of Apple Notes @@ -131,31 +160,20 @@ The first run raises a macOS permission dialog ("Terminal wants access to Notes"); without it every note comes back empty. `--folder Deutsch` exports one Notes folder. -Then convert and import. Look at it first: +Then look at what it would do, and do it: ```bash schulcloud note import notes.ndjson --dry-run +schulcloud note import notes.ndjson # into the server +schulcloud note import notes.ndjson --out ~/Notizen # or to a local folder first ``` ``` would import: 2026-09-15 · Deutsch · Erörterung -would import: 2026-09-16 · LF07 · Subnetting Would import 84 of 91 note(s), skipped 5 with no text, 2 unreadable in Notes. ``` -and then for real, either straight into the server: - -```bash -schulcloud note import notes.ndjson -``` - -or into a local directory, to read through before anything is sent anywhere: - -```bash -schulcloud note import notes.ndjson --out ~/Notizen -``` - -What the import does with each note: +What it does with each note: - **The Notes folder becomes the subject** — its last segment, so `Schule/Deutsch` is `Deutsch`. Notes' own default folders (`Notizen`, `Recently Deleted`) are @@ -171,11 +189,27 @@ What the import does with each note: - **Locked notes cannot be read at all** and are listed by name at the end. Unlock them in Notes and export again. +Imported notes arrive as single-subject notes, not day notes, which is the +shape they were written in. Both kinds coexist. + Re-running the import creates second copies rather than overwriting, since the importer cannot tell an edited note from a new one with the same title. Import once, then keep writing in the new place. -## Writing them from a phone, afterwards +## Reading them + +| Tool | Answers | +|---|---| +| `list_notes` | "what did I write down in Deutsch before the test" — by subject or date, lesson headings included | +| `get_note` | one note in full, by the path everything else prints | +| `search` | notes by their contents, per lesson, next to board text and the inside of PDFs | +| `what_changed` | lessons and notes that appeared or were edited since a date | + +The `pruefungsvorbereitung`, `zusammenfassung` and `tagesvorbereitung` prompts +consult them on their own, and are told to say when a note disagrees with the +uploaded material rather than quietly preferring one. + +## Keeping them somewhere you already sync The notes are files, so any sync tool will do and the server does not need to know which. Bind-mount the folder instead of using the volume: @@ -187,19 +221,37 @@ know which. Bind-mount the folder instead of using the volume: ``` Then point Syncthing, Nextcloud, an Obsidian vault or `git` at -`/home/pi/Notizen` on one side and your phone on the other. New files are picked -up by the next crawl; nothing has to be told about them. +`/home/pi/Notizen`. New files are picked up by the next crawl; nothing has to be +told about them, and the app edits the same files. -Without any of that, `schulcloud note add` and `add_note` still work — they go -over the same authenticated `/api` as everything else the CLI does. +Frontmatter is a small YAML subset — scalars and lists, in either the inline +`[a, b]` or the indented `- item` form editors write — so an Obsidian vault +round-trips. Unknown keys are kept and ignored. + +What the server reads out of a note: + +| Field | Falls back to | +|---|---| +| `title` | the first `# heading`, else the filename without its date | +| `date` | a `YYYY-MM-DD` the **filename** starts with, else nothing | +| `subject` | the first folder name — except a year, which is a filing scheme | +| `tags` | none | +| `courseId` | none — set it to tie a note to a Schulcloud course in `search` | + +`date` also accepts `15.09.2026`, and `fach:` works as a German spelling of +`subject:`. + +**The file's modification time is never used as the date.** An import writes +every note today; dating a year of lessons "today" would make the whole store +useless for revision, which is most of what it is for. ## What the crawl does with them -A **full** crawl reads the directory and indexes every note: title, subject, -tags and body, under the kind `note`, with its path as its id. A **per-course** -refresh leaves them alone — notes belong to the account, not to a course — and -the store carries the previous generation's rows forward, so a per-course crawl -never looks like the notes were deleted. +A **full** crawl reads the directory and indexes every note — one entry per +lesson for a day note, one for the whole note otherwise, under the kind `note`. +A **per-course** refresh leaves them alone, and the store carries the previous +generation's rows forward, so a per-course crawl never looks like the notes were +deleted. Notes are diffed by content, not by modification time, so a sync tool that rewrites a file byte-for-byte does not show up in `what_changed` as an edit. diff --git a/scripts/copy-assets.mjs b/scripts/copy-assets.mjs index 7bd5ff5..430549a 100644 --- a/scripts/copy-assets.mjs +++ b/scripts/copy-assets.mjs @@ -8,7 +8,13 @@ */ import { cp, mkdir } from 'node:fs/promises'; -const assets = [['src/store/migrations', 'dist/store/migrations']]; +const assets = [ + ['src/store/migrations', 'dist/store/migrations'], + // The web app's HTML, CSS and script. Real files rather than strings in a + // module, so an editor and a linter can read them — which only works if the + // build puts them next to the module that serves them. + ['src/http/app', 'dist/http/app'], +]; for (const [from, to] of assets) { await mkdir(to, { recursive: true }); diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index bc535af..af5186f 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -30,6 +30,10 @@ process.env.STATE_DIR = STATE_DIR; // an empty directory is also the only way to assert the empty case. const NOTES_DIR = await mkdtemp(join(tmpdir(), 'schulcloud-smoke-notes-')); process.env.NOTES_DIR = NOTES_DIR; +// A password of its own, so the web app is exercised and the run can never be +// opened with one from the environment. +const WEB_PASSWORD = `smoke-${randomBytes(16).toString('hex')}`; +process.env.WEB_PASSWORD = WEB_PASSWORD; // The app is bound by this script on an ephemeral port, so config.port is unused. const config = loadConfig(); @@ -703,6 +707,123 @@ if (hasUntis) { ); } +console.log('\n== web app =='); +// The one surface here meant for a person rather than a program: a login, the +// day's notes, and the settings page that replaces the Schulcloud token. +{ + const root = `http://127.0.0.1:${port}`; + const jsonHeaders = { 'content-type': 'application/json' }; + + const shell = await fetch(`${root}/app/`); + const shellText = await shell.text(); + check( + 'the app shell is served with a strict content security policy', + shell.ok && + /text\/html/.test(shell.headers.get('content-type') ?? '') && + /default-src 'none'/.test(shell.headers.get('content-security-policy') ?? '') && + /no-store/.test(shell.headers.get('cache-control') ?? ''), + shell.headers.get('content-security-policy')?.slice(0, 40), + ); + check('the shell holds no secret of its own', !shellText.includes(WEB_PASSWORD) && !shellText.includes(TOKEN)); + + const assets = await Promise.all( + ['app.js', 'app.css', 'icon.svg', 'manifest.webmanifest'].map((name) => fetch(`${root}/app/${name}`)), + ); + check('the app\'s assets are served', assets.every((response) => response.ok), assets.map((r) => r.status).join(' ')); + + const anonymousSession = await (await fetch(`${root}/app/session`)).json(); + check('session says "not logged in" rather than failing', anonymousSession.authenticated === false); + + const closed = await fetch(`${root}/api/notes`); + check('/api is closed without a session or a token', closed.status === 401, `got ${closed.status}`); + + const wrong = await fetch(`${root}/app/login`, { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ password: 'not-the-password' }), + }); + check('a wrong password is refused with no detail', wrong.status === 401, `got ${wrong.status}`); + + const login = await fetch(`${root}/app/login`, { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ password: WEB_PASSWORD }), + }); + const setCookie = login.headers.get('set-cookie') ?? ''; + check( + 'logging in sets an HttpOnly, SameSite=Strict session cookie', + login.ok && /HttpOnly/.test(setCookie) && /SameSite=Strict/i.test(setCookie), + setCookie.split(';').slice(1).join(';').trim(), + ); + + const cookie = setCookie.split(';')[0] ?? ''; + const withSession = { cookie }; + + const session = await (await fetch(`${root}/app/session`, { headers: withSession })).json(); + check('the session is recognised', session.authenticated === true); + + const viaSession = await fetch(`${root}/api/notes`, { headers: withSession }); + check('a logged-in browser reaches /api without a token', viaSession.ok, `got ${viaSession.status}`); + + const mcpViaSession = await fetch(`${root}/mcp`, { + method: 'POST', + headers: { ...jsonHeaders, accept: 'application/json, text/event-stream', ...withSession }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }), + }); + // The session is for the app. Nothing in a browser speaks MCP, and a surface + // that is not needed is not offered. + check('the session does not open /mcp', mcpViaSession.status === 401, `got ${mcpViaSession.status}`); + + const tampered = await fetch(`${root}/api/notes`, { headers: { cookie: `${cookie.split('=')[0]}=9999999999999.x.forged` } }); + check('a forged session cookie is refused', tampered.status === 401, `got ${tampered.status}`); + + // The day editor: read the day, write it, read it back. + const day = await (await fetch(`${root}/api/notes/day?date=2026-09-18`, { headers: withSession })).json(); + check( + 'the day route answers with a path, a title and the timetable state', + day.path === '2026/2026-09-18.md' && /2026/.test(day.title) && ['ok', 'off', 'unavailable'].includes(day.timetable), + `${day.title} — timetable ${day.timetable}, ${day.lessons?.length ?? 0} lesson(s)`, + ); + + // A subject no other check uses, so "found by its heading" cannot pass by + // matching the subject note the notes section wrote earlier. + const body = '## 1. Geschichte — 08:00–08:45\n\nWeimarer Republik: Ursachen des Scheiterns.\n'; + const saved = await fetch(`${root}/api/notes/day`, { + method: 'PUT', + headers: { ...jsonHeaders, ...withSession }, + body: JSON.stringify({ date: '2026-09-18', text: body }), + }); + const savedBody = await saved.json(); + check('the day saves', saved.ok && savedBody.path === '2026/2026-09-18.md', `${saved.status}`); + + const conflict = await fetch(`${root}/api/notes/day`, { + method: 'PUT', + headers: { ...jsonHeaders, ...withSession }, + body: JSON.stringify({ date: '2026-09-18', text: 'überschrieben', expectedModifiedAt: '2020-01-01T00:00:00.000Z' }), + }); + check('a save that would clobber a newer version is refused', conflict.status === 409, `got ${conflict.status}`); + + const reread = await (await fetch(`${root}/api/notes/day?date=2026-09-18`, { headers: withSession })).json(); + // Trimmed on both sides: a stored note ends with exactly one newline, which + // is the editor's business and not something to assert on. + check('the refused save changed nothing', reread.text.trim() === body.trim(), reread.text.split('\n')[0]); + + // The lesson heading the page writes has to be the one the index reads back, + // or a day's notes are filed under no subject at all. + const bySubject = await (await fetch(`${root}/api/notes?subject=Geschichte`, { headers: withSession })).json(); + check( + 'a day note is found by a subject only its lesson headings know', + bySubject.count === 1 && bySubject.notes[0]?.path === '2026/2026-09-18.md', + `${bySubject.count} note(s)`, + ); + + const badDate = await fetch(`${root}/api/notes/day?date=2026-02-30`, { headers: withSession }); + check('a date that does not exist is refused', badDate.status === 400, `got ${badDate.status}`); + + const loggedOut = await fetch(`${root}/app/logout`, { method: 'POST', headers: withSession }); + check('logging out clears the cookie', loggedOut.ok && /Max-Age=0/.test(loggedOut.headers.get('set-cookie') ?? '')); +} + console.log('\n== api_get guard rails =='); check('api_get allows /api/ paths', !(await call('api_get', { path: '/api/v3/me' })).isError); check('api_get rejects non-/api path', (await call('api_get', { path: '/etc/passwd' })).isError); diff --git a/src/config.ts b/src/config.ts index c414bb0..fc6e6e4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -63,6 +63,15 @@ export interface Config { /** How often to re-crawl on a timer. Zero = only on demand. */ crawlIntervalMs: number; + /** + * Password for the web app at `/app` — the notes editor and the settings + * page. Unset = the app is not served at all, by the same rule the untis_* + * tools follow: a login screen no password can open is worse than no page. + * + * A credential, and the only one here a person types: it is hashed at + * startup and the plain value is never compared, stored or logged. + */ + webPassword: string | undefined; /** * Where the user's own lesson notes live, as Markdown files. Unset = the * note tools are not offered, the same rule the untis_* tools follow. @@ -181,6 +190,26 @@ function untisConfig(): UntisConfig | undefined { return { server, school, user, secret }; } +/** + * The app password, or undefined when the app is switched off. + * + * A length floor and nothing else: this one is typed by a person on a phone, + * so demanding punctuation would buy little and cost the thing that actually + * matters, which is that they pick something long. The error states the rule + * and never echoes the value. + */ +function webPassword(): string | undefined { + const value = process.env.WEB_PASSWORD; + if (!value) return undefined; + if (value.length < 12) { + throw new Error( + 'WEB_PASSWORD must be at least 12 characters — it is the only thing between the internet and the ' + + 'notes app. A passphrase of three or four words is ideal.', + ); + } + return value; +} + /** Like `int`, but 0 is meaningful (it disables the feature) rather than invalid. */ function intAllowingZero(name: string, fallback: number): number { const raw = process.env[name]?.trim(); @@ -231,6 +260,7 @@ export function loadConfig(): Config { crawlIntervalMs: intAllowingZero('CRAWL_INTERVAL_MS', 6 * 60 * 60_000), // Absolute for the same reason as the mirror: resolveWithin only returns // an absolute path when the root it is given is one. + webPassword: webPassword(), notesDir: process.env.NOTES_DIR?.trim() ? resolve(process.env.NOTES_DIR.trim()) : undefined, notesWritable: !bool('NOTES_READONLY', false), untisHistoryDays: intAllowingZero('UNTIS_HISTORY_DAYS', 180), diff --git a/src/core/day-note.ts b/src/core/day-note.ts new file mode 100644 index 0000000..31cde6d --- /dev/null +++ b/src/core/day-note.ts @@ -0,0 +1,117 @@ +import { germanDay, germanWeekday } from './dates.ts'; +import type { UntisLesson, UntisTimetable } from './untis.ts'; + +/** + * A school day as a note: one file, one heading per lesson. + * + * This is where the two systems meet for the third one. WebUntis is the only + * place that knows which lessons a day actually holds — including that the + * third period was cancelled and the fourth is a substitution — so a page that + * asks someone to write up their day can hand them the day already laid out + * instead of an empty box. The headings it writes are the ones + * `subjectFromHeading` reads back, which is what makes each lesson separately + * searchable afterwards. + */ + +export interface DayLesson { + /** The heading text, without its `##`. */ + heading: string; + subject?: string; + start: string; + end: string; + periodId: number; + /** A substitution: worth knowing while writing, since the teacher differs. */ + changed: boolean; +} + +/** `Montag, 15.09.2026` — what a day note is called. */ +export function dayNoteTitle(date: string): string { + return `${germanWeekday(date)}, ${germanDay(date)}`; +} + +/** + * One lesson's heading: `1. DE — 08:00–08:45 · Meier · R 204`. + * + * The subject leads, because that is the part a person scans for and the part + * the parser reads back. Everything after the first `·` is context and may be + * edited away without breaking anything. + */ +export function lessonHeading(lesson: UntisLesson, index: number): string { + const subject = lesson.subjects[0]; + const name = subject?.longName || subject?.name || 'Stunde'; + const teachers = lesson.teachers.map((teacher) => teacher.name).join(', '); + const rooms = lesson.rooms.map((room) => room.name).join(', '); + return [ + `${index + 1}. ${name} — ${lesson.start}–${lesson.end}`, + teachers || undefined, + rooms ? `R ${rooms}` : undefined, + lesson.changed ? 'Vertretung' : undefined, + ] + .filter(Boolean) + .join(' · '); +} + +/** + * The day's lessons, in order, as headings. + * + * Cancelled periods are left out: nothing was taught in them, and a heading + * with nothing under it is worse than no heading. A substitution is kept and + * marked, because it did happen and its teacher is not the usual one. + */ +export function dayLessons(timetable: UntisTimetable, date: string): DayLesson[] { + const day = timetable.days.find((entry) => entry.date === date); + const held = (day?.lessons ?? []).filter((lesson) => !lesson.cancelled); + return held.map((lesson, index) => { + const subject = lesson.subjects[0]; + return { + heading: lessonHeading(lesson, index), + ...(subject?.longName || subject?.name ? { subject: subject!.longName || subject!.name } : {}), + start: lesson.start, + end: lesson.end, + periodId: lesson.periodId, + changed: lesson.changed, + }; + }); +} + +/** + * The starting text for a day's note: a heading per lesson, blank beneath. + * + * Blank rather than prompted — a placeholder line would have to be deleted in + * every lesson of every day, and half of them would survive into the note. + */ +export function dayNoteSkeleton(lessons: DayLesson[]): string { + if (lessons.length === 0) return ''; + return `${lessons.map((lesson) => `## ${lesson.heading}\n`).join('\n')}`; +} + +/** + * The headings a note is missing, for a day whose timetable is known. + * + * Someone who starts a note before the day ends, or whose timetable changed + * after they started, should be able to top it up without losing what they + * wrote — so the page adds what is absent rather than rebuilding the note. + */ +export function missingHeadings(text: string, lessons: DayLesson[]): DayLesson[] { + const present = new Set( + text + .split('\n') + .map((line) => /^##\s+(.*\S)\s*$/.exec(line)?.[1]) + .filter((heading): heading is string => Boolean(heading)) + .map(headingKey), + ); + return lessons.filter((lesson) => !present.has(headingKey(lesson.heading))); +} + +/** + * What makes two headings "the same lesson". + * + * The period number and the subject, ignoring everything a person may have + * rewritten — a heading edited from `2. DE — 08:50–09:35 · Meier` down to + * `2. Deutsch` is still the second period, and adding it again would give the + * day two. + */ +function headingKey(heading: string): string { + const match = /^\s*(\d{1,2})\s*[.)]/.exec(heading); + return match ? `#${match[1]}` : heading.trim().toLowerCase().replace(/\s+/g, ' '); +} diff --git a/src/core/match.ts b/src/core/match.ts index eead4fb..882f0ac 100644 --- a/src/core/match.ts +++ b/src/core/match.ts @@ -1,6 +1,6 @@ import type { CrawledBoard, Snapshot } from './crawl.ts'; import { h5pSearchText } from './h5p.ts'; -import { noteSearchText } from './notes.ts'; +import { noteSearchText, noteSections } from './notes.ts'; import { matchesAll, snippet, tokenize } from './text.ts'; /** @@ -119,6 +119,26 @@ export function searchSnapshot(snapshot: Snapshot, query: string, limit = 50): H // follows a note back to a course, and the subject is what makes the hit // readable — "Deutsch — my note" rather than a bare path. for (const note of snapshot.notes) { + // Per lesson where the note has lessons, exactly as the index does it — + // otherwise fresh=true would report "Monday" where the index reports + // "Deutsch, Monday", and the two paths would disagree about the same file. + const sections = noteSections(note); + if (sections.length > 0) { + for (const section of sections) { + const haystack = [section.heading, section.text].filter(Boolean).join('\n'); + if (!matchesAll(haystack, terms)) continue; + hits.push({ + courseId: note.courseId ?? '', + courseTitle: section.subject ?? note.subject ?? 'Notizen', + where: `my own note, ${section.heading}${note.date ? `, ${note.date}` : ''}`, + targetId: note.path, + targetKind: 'note', + snippet: snippet(haystack, terms), + }); + } + continue; + } + const haystack = noteSearchText(note); if (!matchesAll(haystack, terms)) continue; hits.push({ diff --git a/src/core/notes.ts b/src/core/notes.ts index 6c10c1b..a46610f 100644 --- a/src/core/notes.ts +++ b/src/core/notes.ts @@ -195,12 +195,20 @@ export function splitFrontmatter(raw: string): { front: NoteFrontmatter; body: s const front: NoteFrontmatter = {}; const extra: Record = {}; - for (const line of block.split(/\r?\n/)) { + const lines = block.split(/\r?\n/); + for (const [index, line] of lines.entries()) { const match = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line.trim()); if (!match) continue; const key = match[1]!.toLowerCase(); - const value = unquote(match[2]!.trim()); - if (!value) continue; + let value = unquote(match[2]!.trim()); + // `tags:` followed by indented `- item` lines is how Obsidian and most + // YAML front ends write a list, and reading only the inline `[a, b]` + // form dropped every tag such an editor had written. + if (!value) { + const items = blockList(lines, index); + if (items.length === 0) continue; + value = `[${items.join(', ')}]`; + } switch (key) { case 'title': front.title = value; @@ -305,6 +313,69 @@ export async function writeNote(root: string, input: NoteInput): Promise<{ note: return { note: await readNoteAt(root, target), appended: false }; } +/** Raised when a note changed under an editor that was holding it open. */ +export class NoteConflict extends Error { + readonly path: string; + readonly modifiedAt: string; + + constructor(path: string, modifiedAt: string) { + super(`The note "${path}" was changed by something else since it was loaded.`); + this.name = 'NoteConflict'; + this.path = path; + this.modifiedAt = modifiedAt; + } +} + +/** + * Replaces a note's contents — what an editor does when it saves. + * + * Separate from `writeNote`, which never overwrites: that rule protects + * `add_note` from clobbering a note it did not mean to touch, and it is exactly + * wrong for a page whose whole job is editing the day in front of you. + * + * `expectedModifiedAt` is how the two stay compatible. The notes are a folder + * that may be synced and is certainly open in more than one place — a phone in + * the lesson, a laptop after it — so a save that would overwrite a version the + * editor never saw is refused rather than silently winning. + */ +export async function replaceNote( + root: string, + relative: string, + input: { title: string; text: string; date?: string; subject?: string; courseId?: string; tags?: string[]; source?: string }, + options: { expectedModifiedAt?: string } = {}, +): Promise { + const path = normalizeRelative(relative); + const absolute = resolveWithin(root, path); + + const info = await stat(absolute).catch(() => undefined); + if (info && options.expectedModifiedAt) { + // Second resolution: some filesystems and most sync tools do not preserve + // milliseconds, so comparing the full ISO string would report a conflict + // for a file nobody touched. + const seen = Math.floor(new Date(options.expectedModifiedAt).getTime() / 1000); + const actual = Math.floor(info.mtime.getTime() / 1000); + if (Number.isFinite(seen) && actual > seen) throw new NoteConflict(path, info.mtime.toISOString()); + } + + await mkdir(dirname(absolute), { recursive: true }); + await writeFile( + absolute, + renderNote( + { + title: input.title, + ...(input.date ? { date: input.date } : {}), + ...pick('subject', input.subject), + ...pick('courseId', input.courseId), + ...pick('source', input.source), + ...(input.tags && input.tags.length > 0 ? { tags: input.tags } : {}), + }, + input.text, + ), + 'utf8', + ); + return readNoteAt(root, path); +} + /** A note as it is stored: frontmatter, then the body. */ export function renderNote(front: NoteFrontmatter, body: string): string { const lines = [ @@ -362,6 +433,86 @@ async function freePath(root: string, relative: string): Promise { throw new Error(`Too many notes named like ${relative}.`); } +// --- sections: one note per school day, one heading per lesson ------------- + +/** + * A `##` section of a note, which for a day note is one lesson. + * + * The shape the notes page writes — one note per school day, titled with the + * date, a heading per timetable lesson, prose and lists and tables beneath — + * is the shape people actually take notes in, and it is the reason this exists. + * Indexing such a note whole would make every hit read "my note, Monday" and + * lose the one thing that makes it findable: which lesson it was. + */ +export interface NoteSection { + /** The heading text, without its `##`. */ + heading: string; + /** The subject read out of the heading, when there is one. */ + subject?: string; + /** The body under the heading, subheadings included. */ + text: string; +} + +/** + * The `##` sections of a note, in order. Empty when it has none — a subject + * note from the import is one piece of prose, and has to stay that way. + * + * Fenced code blocks are skipped, or a `## comment` inside one would split the + * note where nobody wrote a heading. + */ +export function noteSections(note: NoteDoc): NoteSection[] { + const sections: NoteSection[] = []; + let current: { heading: string; lines: string[] } | undefined; + let fence: string | undefined; + + for (const line of note.text.split('\n')) { + const fenceMark = /^\s{0,3}(```+|~~~+)/.exec(line); + if (fenceMark) { + if (!fence) fence = fenceMark[1]![0]; + else if (fenceMark[1]!.startsWith(fence)) fence = undefined; + } + const heading = fence ? null : /^##\s+(.*\S)\s*$/.exec(line); + if (heading) { + if (current) sections.push(toSection(current)); + current = { heading: heading[1]!, lines: [] }; + continue; + } + if (current) current.lines.push(line); + } + if (current) sections.push(toSection(current)); + return sections; +} + +function toSection(raw: { heading: string; lines: string[] }): NoteSection { + const subject = subjectFromHeading(raw.heading); + return { heading: raw.heading, ...(subject ? { subject } : {}), text: raw.lines.join('\n').trim() }; +} + +/** + * The subject a lesson heading names. + * + * Lenient on purpose: the page writes `1. Deutsch — 08:00–08:45 · Meier`, but + * a heading typed by hand is just `Deutsch`, and both have to work. Leading + * period numbers and clock times are stripped, then the subject is whatever + * comes before the first separator. + */ +export function subjectFromHeading(heading: string): string | undefined { + let value = heading.trim(); + value = value.replace(/^\d{1,2}\s*[.)]\s*/, ''); + value = value.replace(/^(\d{1,2}:\d{2}\s*[–—-]\s*\d{1,2}:\d{2}|\d{1,2}:\d{2})\s*[–—·|-]?\s*/, ''); + value = value.split(/\s[–—·|]\s|\s{2,}|\(/)[0]!.trim(); + value = value.replace(/[:,;]+$/, '').trim(); + // A heading that is only a time, a number or punctuation names no subject, + // and guessing one would file the lesson under nonsense. + if (!value || !/[\p{L}]/u.test(value) || value.length > 60) return undefined; + return value; +} + +/** Where a school day's note lives: `2026/2026-09-15.md`. */ +export function dayNotePath(date: string): string { + return `${date.slice(0, 4)}/${date}.md`; +} + // --- matching ------------------------------------------------------------ /** Everything about a note that search should look at, as one string. */ @@ -369,6 +520,19 @@ export function noteSearchText(note: NoteDoc): string { return [note.title, note.subject, note.tags.join(' '), note.text].filter(Boolean).join('\n'); } +/** + * Every subject a note covers: its own, plus one per lesson heading. + * + * A day note has no subject of its own and covers five or six, so asking only + * the frontmatter would make "my Deutsch notes" return nothing at all for + * anyone who writes a note per day. + */ +export function noteSubjects(note: NoteDoc): string[] { + const subjects = note.subject ? [note.subject] : []; + for (const section of noteSections(note)) if (section.subject) subjects.push(section.subject); + return [...new Set(subjects)]; +} + /** Filters a list the way `list_notes` does. Pure, and shared with the CLI. */ export function filterNotes( notes: NoteDoc[], @@ -376,7 +540,7 @@ export function filterNotes( ): NoteDoc[] { const subject = filter.subject?.trim().toLowerCase(); return notes.filter((note) => { - if (subject && !(note.subject ?? '').toLowerCase().includes(subject)) return false; + if (subject && !noteSubjects(note).some((name) => name.toLowerCase().includes(subject))) return false; if (filter.courseId && note.courseId !== filter.courseId) return false; // A note with no date cannot be excluded by a date window without // silently hiding it; undated notes always pass. @@ -432,10 +596,17 @@ function dateFromFileName(fileName: string): string | undefined { return match ? `${match[1]}-${match[2]}-${match[3]}` : undefined; } -/** The first folder is the subject, by the layout `notePathFor` writes. */ +/** + * The first folder is the subject, by the layout `notePathFor` writes. + * + * Except a year: notes filed under `2026/` are filed by date, and calling the + * year a subject would put every lesson of a school year under one. + */ function subjectFromPath(relative: string): string | undefined { const parts = relative.split('/'); - return parts.length > 1 ? parts[0] : undefined; + if (parts.length < 2) return undefined; + const first = parts[0]!; + return /^\d{4}$/.test(first) ? undefined : first; } /** `15.09.2026` and `2026-09-15T08:00:00Z` both mean the same school day. */ @@ -446,6 +617,23 @@ function normalizeDate(value: string): string | undefined { return iso ? iso[1] : undefined; } +/** + * The `- item` lines directly under a `key:` with no inline value. + * + * Stops at the first line that is not one, so a list never swallows the key + * after it. + */ +function blockList(lines: string[], from: number): string[] { + const items: string[] = []; + for (let i = from + 1; i < lines.length; i++) { + const item = /^[ \t]+-\s+(.*)$/.exec(lines[i]!); + if (!item) break; + const value = unquote(item[1]!.trim()); + if (value) items.push(value); + } + return items; +} + function parseList(value: string): string[] { const inner = /^\[(.*)\]$/.exec(value)?.[1] ?? value; return inner diff --git a/src/http/api.ts b/src/http/api.ts index b2bfcf7..3a9c9b5 100644 --- a/src/http/api.ts +++ b/src/http/api.ts @@ -12,7 +12,18 @@ import { type FsErrorCode, type WalkEntry, } from '../core/legacy-files.ts'; -import { filterNotes, NoteNotFound, readNoteAt, readNotes, writeNote } from '../core/notes.ts'; +import { dayLessons, dayNoteSkeleton, dayNoteTitle, missingHeadings } from '../core/day-note.ts'; +import { isCalendarDate, schoolToday } from '../core/dates.ts'; +import { + dayNotePath, + NoteConflict, + NoteNotFound, + filterNotes, + readNoteAt, + readNotes, + replaceNote, + writeNote, +} from '../core/notes.ts'; import { resolveWithin } from '../core/paths.ts'; import { TokenRejected } from '../core/session-token.ts'; import type { Services } from '../services.ts'; @@ -307,6 +318,94 @@ export function createApiRouter(services: Services): Router { } }); + // --- one school day, as the notes page edits it ------------------------- + // + // The page is a Markdown editor for a single file, so these two are `GET the + // day` and `PUT the day`. What makes them worth their own routes rather than + // the generic ones above is the skeleton: WebUntis is the only thing that + // knows which lessons a day held, and handing someone their day already laid + // out is the difference between a note per day and an empty box. + + router.get('/notes/day', async (req: Request, res: Response) => { + const root = services.config.notesDir; + if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR }); + const date = stringParam(req.query.date) ?? schoolToday(); + if (!isCalendarDate(date)) { + return res.status(400).json({ error: 'invalid', message: `Not a date in the calendar: ${date}.` }); + } + try { + const path = dayNotePath(date); + const note = await readNoteAt(root, path).catch((error: unknown) => { + if (error instanceof NoteNotFound) return undefined; + throw error; + }); + + // Never fatal, and reported rather than hidden: without a key, or with + // WebUntis down, the page still has to open — it just cannot offer the + // lessons, and saying so beats an empty skeleton that looks like a day + // with no school. + let lessons: ReturnType = []; + let timetable: 'ok' | 'off' | 'unavailable' = services.untis ? 'ok' : 'off'; + if (services.untis) { + try { + lessons = dayLessons(await services.untis.timetable(date, date), date); + } catch { + timetable = 'unavailable'; + } + } + + return res.json({ + date, + path, + title: dayNoteTitle(date), + exists: Boolean(note), + text: note?.text ?? '', + modifiedAt: note?.modifiedAt ?? null, + timetable, + lessons, + skeleton: dayNoteSkeleton(lessons), + // What the page would add to a note already started, so "top up the + // day" never rewrites what is there. + missing: note ? dayNoteSkeleton(missingHeadings(note.text, lessons)) : '', + }); + } catch (error) { + return fail(res, error, 'read a day note'); + } + }); + + router.put('/notes/day', express.json({ limit: '2mb' }), async (req: Request, res: Response) => { + const root = services.config.notesDir; + if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR }); + if (!services.config.notesWritable) { + return res.status(403).json({ error: 'notes_readonly', message: 'This server was started with NOTES_READONLY.' }); + } + const body = (req.body ?? {}) as { date?: unknown; text?: unknown; expectedModifiedAt?: unknown }; + const date = typeof body.date === 'string' ? body.date : ''; + if (!isCalendarDate(date)) { + return res.status(400).json({ error: 'invalid', message: `Not a date in the calendar: ${date || '(none)'}.` }); + } + if (typeof body.text !== 'string') { + return res.status(400).json({ error: 'invalid', message: 'A day note needs its text.' }); + } + try { + const note = await replaceNote( + root, + dayNotePath(date), + { title: dayNoteTitle(date), text: body.text, date, source: 'notes-page' }, + typeof body.expectedModifiedAt === 'string' ? { expectedModifiedAt: body.expectedModifiedAt } : {}, + ); + return res.json({ path: note.path, modifiedAt: note.modifiedAt, bytes: note.bytes }); + } catch (error) { + // A clash is the caller's to resolve, not a fault: the page shows both + // and lets the person decide, which is the only safe answer when the + // notes folder is synced and open in two places. + if (error instanceof NoteConflict) { + return res.status(409).json({ error: 'conflict', message: error.message, modifiedAt: error.modifiedAt }); + } + return fail(res, error, 'save a day note'); + } + }); + router.get('/token', (_req: Request, res: Response) => { res.json(tokenStatus(services)); }); diff --git a/src/http/app-page.ts b/src/http/app-page.ts new file mode 100644 index 0000000..cab93f3 --- /dev/null +++ b/src/http/app-page.ts @@ -0,0 +1,135 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import express, { type Request, type Response, type Router } from 'express'; +import type { Config } from '../config.ts'; +import { createWebAuth, isSecureRequest, sessionAuth, type WebAuth } from './web-auth.ts'; + +/** + * `/app` — the notes app, for a person rather than a program. + * + * Everything else this server exposes is for a machine with a token. This is + * the one surface a human opens on a phone, so it gets a login, a session + * cookie and an interface: the day's notes, and the settings page where the + * Schulcloud token is replaced when it expires. + * + * It is served only when `WEB_PASSWORD` is set, by the same rule as the + * `untis_*` tools and the note tools: an app whose login nothing can open is + * worse than no app, because it looks like a way in. + * + * The assets are files, not strings in this module. They are real HTML, CSS + * and JavaScript that an editor and a linter understand, and the content + * security policy forbids inline script anyway — so the only thing gained by + * embedding them would be a build step that no longer copies them, and the + * only thing lost would be every tool that reads them. + */ + +/** No outside resources at all, and no inline script. Nothing here needs either. */ +const CSP = + "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; " + + "connect-src 'self'; manifest-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; + +const HEADERS: Record = { + 'Content-Security-Policy': CSP, + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', + // The app reflects an account's data; a shared phone should not show it from + // the back-forward cache after a logout. + 'Cache-Control': 'no-store', +}; + +const ASSETS: Record = { + '/': { file: 'index.html', type: 'text/html; charset=utf-8' }, + '/index.html': { file: 'index.html', type: 'text/html; charset=utf-8' }, + '/app.css': { file: 'app.css', type: 'text/css; charset=utf-8' }, + '/app.js': { file: 'app.js', type: 'text/javascript; charset=utf-8' }, + '/icon.svg': { file: 'icon.svg', type: 'image/svg+xml' }, + '/manifest.webmanifest': { file: 'manifest.webmanifest', type: 'application/manifest+json' }, +}; + +/** + * Read once at startup, from next to this module. + * + * `import.meta.dirname` resolves to `src/http` when the tree is run directly + * and `dist/http` after a build, and `scripts/copy-assets.mjs` puts the folder + * in both — so there is one path and no branch on how the server was started. + */ +const assetRoot = join(import.meta.dirname, 'app'); +const cache = new Map(); + +function asset(file: string): Buffer { + let bytes = cache.get(file); + if (!bytes) { + bytes = readFileSync(join(assetRoot, file)); + cache.set(file, bytes); + } + return bytes; +} + +export interface AppSurface { + router: Router; + /** The gate `/api` also accepts, so the app's own fetches need no token. */ + auth: WebAuth; +} + +export function createAppRouter(config: Config): AppSurface | undefined { + const auth = createWebAuth(config.webPassword); + if (!auth.enabled) return undefined; + + const router = express.Router(); + const requireSession = sessionAuth(auth); + + router.use((_req: Request, res: Response, next) => { + for (const [name, value] of Object.entries(HEADERS)) res.setHeader(name, value); + next(); + }); + + // The shell is public: it is the login screen, and it holds nothing. Every + // byte of data it goes on to show comes from /api, behind the session. + router.get(/^\/(index\.html|app\.css|app\.js|icon\.svg|manifest\.webmanifest)?$/, (req: Request, res: Response) => { + const entry = ASSETS[req.path] ?? ASSETS['/']!; + res.type(entry.type).send(asset(entry.file)); + }); + + router.post('/login', express.json({ limit: '4kb' }), (req: Request, res: Response) => { + const password = (req.body as { password?: unknown } | undefined)?.password; + if (typeof password !== 'string' || password.length === 0) { + return res.status(400).json({ error: 'invalid', message: 'Passwort fehlt.' }); + } + // The address is the rate-limit key. Behind Caddy every request comes from + // the proxy, so the forwarded address is what distinguishes callers; it is + // spoofable by anyone who can reach this process directly, which on this + // deployment is nobody. + const from = (req.get('x-forwarded-for') ?? '').split(',')[0]?.trim() || req.ip || 'unknown'; + const result = auth.check(password, from); + if (!result.ok) { + if (result.retryAfterSeconds !== undefined) { + res.setHeader('Retry-After', String(result.retryAfterSeconds)); + return res.status(429).json({ + error: 'too_many_attempts', + message: `Zu viele Fehlversuche. In ${Math.ceil(result.retryAfterSeconds / 60)} Minute(n) erneut versuchen.`, + }); + } + // Deliberately no detail, and the same shape for every miss. + return res.status(401).json({ error: 'unauthorized' }); + } + res.setHeader('Set-Cookie', auth.cookie(auth.mint(), { secure: isSecureRequest(req) })); + return res.json({ authenticated: true }); + }); + + router.post('/logout', (req: Request, res: Response) => { + res.setHeader('Set-Cookie', auth.clearCookie({ secure: isSecureRequest(req) })); + return res.json({ authenticated: false }); + }); + + // Always 200: "are you logged in" is not itself a protected question, and a + // 401 here would make the first load of the login screen look like an error. + router.get('/session', (req: Request, res: Response) => { + return res.json({ authenticated: auth.verify(req.get('cookie')) }); + }); + + // Anything else under /app needs the session — there is nothing else to + // serve, but a 404 that leaks the shape of the tree is still a 404 too many. + router.use(requireSession, (_req: Request, res: Response) => res.status(404).json({ error: 'not_found' })); + + return { router, auth }; +} diff --git a/src/http/app/app.css b/src/http/app/app.css new file mode 100644 index 0000000..a0bca6c --- /dev/null +++ b/src/http/app/app.css @@ -0,0 +1,188 @@ +/* + * The app is used with one thumb, in a lesson, on a phone that may be at 10%. + * Everything below follows from that: one column, large touch targets, the + * editor taking every pixel that is not navigation, and no webfont — the CSP + * forbids outside resources anyway, and a font that has not loaded is a blank + * screen in a classroom with no signal. + */ + +:root { + color-scheme: light dark; + --bg: #ffffff; + --fg: #1f2328; + --muted: #656d76; + --line: #d0d7de; + --accent: #1f6feb; + --ok: #1a7f37; + --error: #cf222e; + --warn: #9a6700; + --card: #f6f8fa; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d1117; + --fg: #e6edf3; + --muted: #8b949e; + --line: #30363d; + --accent: #4493f8; + --ok: #3fb950; + --error: #f85149; + --warn: #d29922; + --card: #161b22; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + /* Fills the viewport on a phone, where 100vh lies about the toolbar. */ + min-height: 100dvh; + display: flex; + flex-direction: column; + padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); +} + +.screen { display: flex; flex-direction: column; flex: 1; min-height: 0; } +[hidden] { display: none !important; } + +/* --- chrome ------------------------------------------------------------ */ + +header { border-bottom: 1px solid var(--line); } + +.tabs { display: flex; } + +.tab { + flex: 1; + padding: 0.9rem 0.5rem; + border: 0; + border-bottom: 2px solid transparent; + background: none; + color: var(--muted); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.tab[aria-current="page"] { color: var(--fg); border-bottom-color: var(--accent); } + +.view { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0.75rem; gap: 0.5rem; } + +/* --- the day bar ------------------------------------------------------- */ + +.daybar { display: flex; align-items: center; gap: 0.5rem; } + +.daybar button { + flex: 0 0 auto; + width: 2.75rem; + height: 2.75rem; + font-size: 1.5rem; + line-height: 1; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--card); + color: var(--fg); + cursor: pointer; +} + +.daybar-centre { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 0.15rem; } +.daybar-centre strong { font-size: 1.05rem; } +.daybar-centre input { border: 0; background: none; color: var(--muted); font: inherit; font-size: 0.85rem; } + +/* --- the editor -------------------------------------------------------- */ + +textarea { + flex: 1; + min-height: 12rem; + width: 100%; + padding: 0.75rem; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--bg); + color: var(--fg); + /* Monospace: the notes are Markdown, and headings and list markers have to + line up to be read back as structure. */ + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.95rem; + line-height: 1.5; + resize: none; +} + +.actions { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; } + +button { + padding: 0.65rem 1rem; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--card); + color: var(--fg); + font: inherit; + cursor: pointer; +} + +button:disabled { opacity: 0.5; cursor: default; } + +#save, #login-form button, #token-form button { + background: var(--accent); + border-color: var(--accent); + color: #ffffff; + font-weight: 600; +} + +/* --- text -------------------------------------------------------------- */ + +.status { margin: 0; color: var(--muted); font-size: 0.85rem; min-height: 1.2em; } +.hint { color: var(--muted); font-size: 0.85rem; } +.ok { color: var(--ok); } +.error { color: var(--error); margin: 0.5rem 0 0; } +.warn { color: var(--warn); } + +.conflict { + margin: 0; + padding: 0.6rem 0.75rem; + border: 1px solid var(--warn); + border-radius: 0.5rem; + color: var(--warn); + font-size: 0.9rem; +} + +/* --- cards (login, settings) ------------------------------------------- */ + +.card { + margin: 0.75rem; + padding: 1rem; + border: 1px solid var(--line); + border-radius: 0.75rem; + background: var(--card); +} + +.card h1, .card h2 { margin-top: 0; font-size: 1.15rem; } + +label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 600; font-size: 0.9rem; } + +input[type="password"], input[type="text"] { + width: 100%; + padding: 0.7rem; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--bg); + color: var(--fg); + font: inherit; +} + +#login { justify-content: center; } +#login .card { width: min(24rem, 100%); align-self: center; } +#login button { width: 100%; margin-top: 1rem; } + +.steps { margin: 0.5rem 0; padding-left: 1.1rem; color: var(--muted); font-size: 0.85rem; line-height: 1.5; } +.steps code { font-family: ui-monospace, monospace; } + +#token-form button, #logout { margin-top: 0.75rem; } + +dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 0.35rem 0.75rem; font-size: 0.9rem; } +dt { color: var(--muted); } +dd { margin: 0; } diff --git a/src/http/app/app.js b/src/http/app/app.js new file mode 100644 index 0000000..4040f31 --- /dev/null +++ b/src/http/app/app.js @@ -0,0 +1,490 @@ +'use strict'; + +/* + * The notes app. + * + * One school day is one note, one lesson is one `##` heading, and the server + * builds the headings from WebUntis — so opening the app during a free period + * gives you the day already laid out rather than an empty box. That shape is + * also what makes each lesson separately searchable afterwards, which is the + * whole reason the notes are worth writing here rather than in Notes.app. + * + * Three rules this file exists to honour: + * + * - **Never lose what was typed.** Every keystroke goes to localStorage, and a + * draft that is newer than the server's copy survives a dead connection, a + * locked phone and a closed tab. A note taken in a lesson cannot be retaken. + * - **Never silently overwrite.** Saves carry the modification time the editor + * loaded; the server refuses one that would clobber a version this editor + * never saw, and the banner then makes it the person's decision. + * - **Say what state it is in.** "Gespeichert 14:02", "Nicht gespeichert", + * "Offline — lokal gesichert". A silent editor over a flaky connection is + * indistinguishable from one that is losing your work. + */ + +const AUTOSAVE_MS = 2500; +const DRAFT_PREFIX = 'schulcloud-mcp/draft/'; + +const ui = { + login: document.getElementById('login'), + loginForm: document.getElementById('login-form'), + password: document.getElementById('password'), + loginError: document.getElementById('login-error'), + app: document.getElementById('app'), + tabNotes: document.getElementById('tab-notes'), + tabSettings: document.getElementById('tab-settings'), + viewNotes: document.getElementById('view-notes'), + viewSettings: document.getElementById('view-settings'), + prev: document.getElementById('prev'), + next: document.getElementById('next'), + dayTitle: document.getElementById('day-title'), + dayDate: document.getElementById('day-date'), + dayStatus: document.getElementById('day-status'), + conflict: document.getElementById('day-conflict'), + editor: document.getElementById('editor'), + save: document.getElementById('save'), + fill: document.getElementById('fill'), + lessonsHint: document.getElementById('lessons-hint'), + tokenState: document.getElementById('token-state'), + tokenForm: document.getElementById('token-form'), + jwt: document.getElementById('jwt'), + tokenResult: document.getElementById('token-result'), + serverState: document.getElementById('server-state'), + logout: document.getElementById('logout'), +}; + +/** Everything about the day currently open. */ +const day = { + date: today(), + path: '', + /** The server's modification time for the loaded note, or null if there is none. */ + modifiedAt: null, + /** The text as the server last confirmed it, to tell "dirty" from "saved". */ + saved: '', + /** Headings the timetable has and the note does not. */ + missing: '', + dirty: false, + conflicted: false, + timer: 0, +}; + +// --- plumbing ------------------------------------------------------------ + +async function api(path, options) { + const response = await fetch(path, { + credentials: 'same-origin', + ...options, + headers: { accept: 'application/json', ...(options && options.headers) }, + }); + if (response.status === 401) { + showLogin(); + throw new Error('unauthorized'); + } + let body = null; + try { + body = await response.json(); + } catch (error) { + body = null; + } + if (!response.ok) { + const failure = new Error((body && (body.message || body.error)) || 'HTTP ' + response.status); + failure.status = response.status; + failure.body = body; + throw failure; + } + return body; +} + +function json(method, path, payload) { + return api(path, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }); +} + +function today() { + // The device's own date. The server keeps school dates in Europe/Berlin, but + // the phone in the lesson is in that timezone by definition. + const now = new Date(); + return [now.getFullYear(), pad(now.getMonth() + 1), pad(now.getDate())].join('-'); +} + +function pad(value) { + return String(value).padStart(2, '0'); +} + +function shiftDate(date, days) { + // Noon, so a daylight-saving change cannot push the result onto the + // neighbouring day. + const at = new Date(date + 'T12:00:00'); + at.setDate(at.getDate() + days); + return [at.getFullYear(), pad(at.getMonth() + 1), pad(at.getDate())].join('-'); +} + +function clock() { + const now = new Date(); + return pad(now.getHours()) + ':' + pad(now.getMinutes()); +} + +// --- drafts: the safety net --------------------------------------------- + +function draftKey(date) { + return DRAFT_PREFIX + date; +} + +function saveDraft() { + try { + localStorage.setItem(draftKey(day.date), JSON.stringify({ text: ui.editor.value, at: Date.now() })); + } catch (error) { + // A full or disabled localStorage must not break typing; the server copy + // is still the real one. + } +} + +function readDraft(date) { + try { + const raw = localStorage.getItem(draftKey(date)); + return raw ? JSON.parse(raw) : null; + } catch (error) { + return null; + } +} + +function clearDraft(date) { + try { + localStorage.removeItem(draftKey(date)); + } catch (error) { + // Nothing to do: a stale draft is only ever offered, never forced. + } +} + +// --- the day ------------------------------------------------------------- + +function setStatus(message, kind) { + ui.dayStatus.textContent = message; + ui.dayStatus.className = 'status' + (kind ? ' ' + kind : ''); +} + +async function loadDay(date) { + // Anything unsaved goes to the draft before the view moves, or switching + // days would be a way to lose a lesson. + if (day.dirty) saveDraft(); + window.clearTimeout(day.timer); + + day.date = date; + day.conflicted = false; + ui.conflict.hidden = true; + ui.dayDate.value = date; + ui.editor.disabled = true; + setStatus('Wird geladen …'); + + let info; + try { + info = await api('/api/notes/day?date=' + encodeURIComponent(date)); + } catch (error) { + if (error.message === 'unauthorized') return; + ui.dayTitle.textContent = date; + // A reply with a status is the server saying no — most often that it keeps + // no notes at all — and reporting that as "offline" would send someone + // looking at their signal instead of at NOTES_DIR. + if (error.status) { + ui.editor.value = ''; + ui.editor.disabled = true; + setStatus(error.message, 'error'); + ui.lessonsHint.textContent = ''; + return; + } + // No status: the request never arrived. Fall back to whatever this device + // has, rather than an empty editor that looks like a day with no notes. + const draft = readDraft(date); + ui.editor.disabled = false; + ui.editor.value = draft ? draft.text : ''; + day.saved = ''; + day.modifiedAt = null; + day.dirty = Boolean(draft); + setStatus( + draft ? 'Offline — lokale Fassung, nicht gespeichert.' : 'Offline — keine Verbindung zum Server.', + 'warn', + ); + return; + } + + day.path = info.path; + day.modifiedAt = info.modifiedAt; + day.missing = info.missing || ''; + ui.dayTitle.textContent = info.title; + + const server = info.exists ? info.text : info.skeleton; + const draft = readDraft(date); + // A draft only wins when it differs from what the server holds; otherwise it + // is just the last save echoed back and offering it would be noise. + const useDraft = draft && draft.text !== server && draft.text.trim() !== ''; + + ui.editor.value = useDraft ? draft.text : server; + ui.editor.disabled = false; + day.saved = info.exists ? info.text : ''; + day.dirty = ui.editor.value !== day.saved; + + if (useDraft) { + setStatus('Lokale, noch nicht gespeicherte Fassung wiederhergestellt.', 'warn'); + } else if (info.exists) { + setStatus('Gespeichert.'); + } else if (info.skeleton) { + setStatus('Neuer Tag — Stunden aus WebUntis eingetragen.'); + } else { + setStatus('Neuer Tag.'); + } + + describeLessons(info); + ui.fill.hidden = !day.missing; +} + +function describeLessons(info) { + if (info.timetable === 'off') { + ui.lessonsHint.textContent = 'Ohne WebUntis-Schlüssel: Überschriften selbst anlegen.'; + return; + } + if (info.timetable === 'unavailable') { + ui.lessonsHint.textContent = 'WebUntis nicht erreichbar — Stunden fehlen.'; + return; + } + const count = (info.lessons || []).length; + ui.lessonsHint.textContent = count === 0 ? 'Kein Unterricht an diesem Tag.' : count + ' Stunde(n) laut Stundenplan.'; +} + +function markDirty() { + day.dirty = ui.editor.value !== day.saved; + saveDraft(); + if (day.conflicted) return; + if (day.dirty) setStatus('Nicht gespeichert …'); + window.clearTimeout(day.timer); + day.timer = window.setTimeout(() => void saveDay(true), AUTOSAVE_MS); +} + +async function saveDay(automatic) { + window.clearTimeout(day.timer); + if (!day.dirty && automatic) return; + const text = ui.editor.value; + setStatus('Wird gespeichert …'); + + try { + const result = await json('PUT', '/api/notes/day', { + date: day.date, + text, + // Absent for a note that does not exist yet: there is nothing to clash + // with, and sending null would look like "I saw no version". + ...(day.modifiedAt ? { expectedModifiedAt: day.modifiedAt } : {}), + }); + day.saved = text; + day.modifiedAt = result.modifiedAt; + day.dirty = false; + day.conflicted = false; + ui.conflict.hidden = true; + clearDraft(day.date); + setStatus('Gespeichert ' + clock() + '.', 'ok'); + } catch (error) { + if (error.message === 'unauthorized') return; + if (error.status === 409) { + // Stop autosaving: every further attempt would fail the same way, and + // the choice of which version wins is not ours to make. + day.conflicted = true; + ui.conflict.hidden = false; + ui.conflict.textContent = + 'Diese Notiz wurde anderswo geändert, seit sie hier geöffnet wurde. ' + + '„Neu laden" verwirft, was hier steht; „Trotzdem speichern" überschreibt die andere Fassung. ' + + 'Deine Fassung ist lokal gesichert.'; + ensureConflictButtons(); + setStatus('Nicht gespeichert — Konflikt.', 'error'); + return; + } + if (error.status === 403) { + setStatus('Der Server nimmt keine Änderungen an (NOTES_READONLY).', 'error'); + return; + } + setStatus('Nicht gespeichert — ' + error.message + '. Lokal gesichert.', 'error'); + } +} + +/** The two ways out of a conflict, added once and only when one happens. */ +function ensureConflictButtons() { + if (document.getElementById('conflict-reload')) return; + const reload = document.createElement('button'); + reload.id = 'conflict-reload'; + reload.type = 'button'; + reload.textContent = 'Neu laden'; + reload.addEventListener('click', () => { + clearDraft(day.date); + void loadDay(day.date); + }); + + const force = document.createElement('button'); + force.id = 'conflict-force'; + force.type = 'button'; + force.textContent = 'Trotzdem speichern'; + force.addEventListener('click', () => { + day.modifiedAt = null; + day.conflicted = false; + ui.conflict.hidden = true; + void saveDay(false); + }); + + ui.conflict.append(document.createElement('br'), reload, document.createTextNode(' '), force); +} + +// --- settings ------------------------------------------------------------ + +async function loadSettings() { + ui.tokenState.textContent = 'Wird geladen …'; + try { + const info = await api('/api/token'); + const budget = info.keepalive && info.keepalive.budgetSeconds; + ui.tokenState.textContent = + 'Noch ' + info.daysLeft + ' Tag(e) gültig' + + (budget ? ', Sitzung noch ' + Math.round(budget / 60) + ' min' : '') + + ' (' + info.source + ').'; + ui.tokenState.className = 'status' + (info.daysLeft <= 3 ? ' warn' : ''); + } catch (error) { + if (error.message === 'unauthorized') return; + ui.tokenState.textContent = 'Token-Status nicht lesbar: ' + error.message; + ui.tokenState.className = 'status error'; + } + + ui.serverState.replaceChildren(); + try { + const status = await api('/api/status'); + addFact('Index', status.crawlId ? 'Stand ' + status.crawlId + ', ' + status.nodes + ' Einträge' : 'leer'); + addFact('Dateien', status.files + ' (' + status.extracted + ' mit Text)'); + if (status.indexer && status.indexer.running) addFact('Gerade', 'Durchlauf läuft'); + } catch (error) { + addFact('Index', 'nicht verfügbar'); + } + try { + const notes = await api('/api/notes?limit=1'); + addFact('Notizen', notes.count + ' · ' + notes.root + (notes.writable ? '' : ' (schreibgeschützt)')); + } catch (error) { + addFact('Notizen', 'nicht verfügbar'); + } +} + +function addFact(term, value) { + const dt = document.createElement('dt'); + dt.textContent = term; + const dd = document.createElement('dd'); + dd.textContent = value; + ui.serverState.append(dt, dd); +} + +// --- views --------------------------------------------------------------- + +function showLogin() { + ui.app.hidden = true; + ui.login.hidden = false; + ui.password.focus(); +} + +function showApp() { + ui.login.hidden = true; + ui.app.hidden = false; +} + +function showTab(name) { + const notes = name !== 'settings'; + ui.viewNotes.hidden = !notes; + ui.viewSettings.hidden = notes; + ui.tabNotes.setAttribute('aria-current', notes ? 'page' : 'false'); + ui.tabSettings.setAttribute('aria-current', notes ? 'false' : 'page'); + if (!notes) void loadSettings(); +} + +// --- wiring -------------------------------------------------------------- + +ui.loginForm.addEventListener('submit', async (event) => { + event.preventDefault(); + ui.loginError.textContent = ''; + try { + await json('POST', '/app/login', { password: ui.password.value }); + ui.password.value = ''; + showApp(); + await loadDay(day.date); + } catch (error) { + ui.loginError.textContent = + error.status === 429 ? 'Zu viele Versuche. ' + error.message : 'Passwort falsch.'; + } +}); + +ui.logout.addEventListener('click', async () => { + // The draft stays: logging out is not the same as discarding a lesson. + await json('POST', '/app/logout', {}).catch(() => {}); + showLogin(); +}); + +ui.tabNotes.addEventListener('click', () => showTab('notes')); +ui.tabSettings.addEventListener('click', () => showTab('settings')); + +ui.prev.addEventListener('click', () => void loadDay(shiftDate(day.date, -1))); +ui.next.addEventListener('click', () => void loadDay(shiftDate(day.date, 1))); +ui.dayDate.addEventListener('change', () => { + if (ui.dayDate.value) void loadDay(ui.dayDate.value); +}); + +ui.editor.addEventListener('input', markDirty); +ui.save.addEventListener('click', () => void saveDay(false)); + +ui.fill.addEventListener('click', () => { + // Appended, never merged into place: the person's own text is not something + // to reorder, and a heading in the wrong order is trivial to move. + const separator = ui.editor.value.trim() ? '\n\n' : ''; + ui.editor.value = ui.editor.value.replace(/\s*$/, '') + separator + day.missing; + day.missing = ''; + ui.fill.hidden = true; + markDirty(); +}); + +// A phone locking, the app going to the background, or the tab closing: all of +// them end the session without a "save" ever being pressed. +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden' && day.dirty) { + saveDraft(); + if (!day.conflicted) void saveDay(true); + } +}); + +window.addEventListener('beforeunload', (event) => { + if (!day.dirty) return; + saveDraft(); + event.preventDefault(); + event.returnValue = ''; +}); + +ui.tokenForm.addEventListener('submit', async (event) => { + event.preventDefault(); + ui.tokenResult.textContent = 'Wird geprüft …'; + ui.tokenResult.className = 'status'; + try { + const result = await json('PUT', '/api/token', { jwt: ui.jwt.value }); + ui.jwt.value = ''; + ui.tokenResult.textContent = result.changed + ? 'Ersetzt. Noch ' + result.daysLeft + ' Tag(e) gültig.' + (result.persisted ? '' : ' (Nicht dauerhaft gespeichert.)') + : 'Das ist der Token, der bereits benutzt wird.'; + ui.tokenResult.className = 'status ok'; + void loadSettings(); + } catch (error) { + if (error.message === 'unauthorized') return; + ui.tokenResult.textContent = error.message; + ui.tokenResult.className = 'status error'; + } +}); + +// --- start --------------------------------------------------------------- + +void (async () => { + try { + const session = await api('/app/session'); + if (!session.authenticated) { + showLogin(); + return; + } + } catch (error) { + showLogin(); + return; + } + showApp(); + await loadDay(day.date); +})(); diff --git a/src/http/app/icon.svg b/src/http/app/icon.svg new file mode 100644 index 0000000..12a1ea9 --- /dev/null +++ b/src/http/app/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/http/app/index.html b/src/http/app/index.html new file mode 100644 index 0000000..a5cf76f --- /dev/null +++ b/src/http/app/index.html @@ -0,0 +1,87 @@ + + + + + + +Schulcloud — Notizen + + + + + + + + + + + + + + + diff --git a/src/http/app/manifest.webmanifest b/src/http/app/manifest.webmanifest new file mode 100644 index 0000000..6f605f4 --- /dev/null +++ b/src/http/app/manifest.webmanifest @@ -0,0 +1,14 @@ +{ + "name": "Schulcloud Notizen", + "short_name": "Notizen", + "description": "Notizen zum Schultag, Stunde für Stunde.", + "start_url": "./", + "scope": "./", + "display": "standalone", + "orientation": "portrait", + "background_color": "#ffffff", + "theme_color": "#1f6feb", + "icons": [ + { "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" } + ] +} diff --git a/src/http/auth.ts b/src/http/auth.ts index 701b1a3..3b34bc2 100644 --- a/src/http/auth.ts +++ b/src/http/auth.ts @@ -12,11 +12,25 @@ import type { NextFunction, Request, Response } from 'express'; * * A route can accept more than one token: `/mcp` also takes the connector * token claude.ai stores, which `/api` refuses. + * + * `alsoAccept` is the other kind of caller: a person logged into the web app, + * carrying a session cookie rather than a token. `/api` takes it because the + * app is built on `/api` and a session *is* the user; `/mcp` does not, because + * nothing in a browser speaks MCP and a surface not needed is a surface not + * offered. */ -export function bearerAuth(accepted: string | string[]) { +export function bearerAuth(accepted: string | string[], alsoAccept?: (req: Request) => boolean) { const expected = (Array.isArray(accepted) ? accepted : [accepted]).map((token) => Buffer.from(token, 'utf8')); return function authenticate(req: Request, res: Response, next: NextFunction): void { + // A logged-in browser instead of a token. Checked first because the app's + // own fetches carry no Authorization header at all, and running them + // through the token comparison would only waste it. + if (alsoAccept?.(req)) { + next(); + return; + } + const presented = extractToken(req.get('authorization'), req.get('x-api-key') ?? req.get('x-auth-token')); // Every token is compared even after a match, so the timing does not // tell which one was presented. diff --git a/src/http/server.ts b/src/http/server.ts index 807223f..1eb2cf3 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -6,6 +6,7 @@ import type { Config } from '../config.ts'; import { createServer } from '../mcp/server.ts'; import type { Services } from '../services.ts'; import { createApiRouter } from './api.ts'; +import { createAppRouter } from './app-page.ts'; import { bearerAuth, pathSecret } from './auth.ts'; import { tokenPage, tokenScript } from './token-page.ts'; @@ -61,9 +62,17 @@ export function createHttpApp(config: Config, services?: Services): express.Expr // connector token opens /mcp alone. claude.ai stores it as a request header, // and a credential held by a third party should reach the read-only tools, // not /api, which can replace the Schulcloud token and stream the file mirror. + // The web app, when a password is configured. Mounted before the token gate + // so its login screen is reachable without one — it is the thing that issues + // the session everything else then accepts. + const appSurface = services ? createAppRouter(config) : undefined; + if (appSurface) app.use('/app', appSurface.router); + + const loggedIn = appSurface ? (req: Request) => appSurface.auth.verify(req.get('cookie')) : undefined; + if (config.authToken) { app.use(MCP_PATH, bearerAuth(config.connectorToken ? [config.authToken, config.connectorToken] : config.authToken)); - app.use(API_PATH, bearerAuth(config.authToken)); + app.use(API_PATH, bearerAuth(config.authToken, loggedIn)); } else { console.warn( '[schulcloud-mcp] MCP_AUTH_TOKEN is not set — the endpoint is UNAUTHENTICATED. ' + diff --git a/src/http/web-auth.ts b/src/http/web-auth.ts new file mode 100644 index 0000000..ca332c7 --- /dev/null +++ b/src/http/web-auth.ts @@ -0,0 +1,197 @@ +import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'; +import type { NextFunction, Request, Response } from 'express'; + +/** + * A login for the web app, as opposed to a token for a machine. + * + * Everything else here authenticates a program: the CLI and Claude send a + * bearer token they were configured with. A person on a phone cannot be asked + * to paste a 64-character token into a browser every time they want to write + * down what happened in German, so the app gets a password and a session + * cookie — which is a different credential with a different lifetime, not a + * second way to present the same one. + * + * What that buys and what it costs: + * + * - **The password is never stored, compared or logged in the clear.** It is + * put through scrypt at startup and only the hash is kept; a login hashes + * the attempt and compares in constant time. + * - **The session key is derived from the password**, so changing the password + * invalidates every session that exists — which is the behaviour anyone + * changing a password expects, and it needs no second secret and no storage. + * - **The cookie is HttpOnly and SameSite=Strict**, so no script can read it + * and no other site can cause a request that carries it. That is what stands + * in for CSRF tokens here. + * - **Login is rate-limited per address**, because the endpoint is on the + * internet and a password is guessable in a way a 32-byte token is not. The + * scrypt cost is itself a brute-force defence and, without a limiter, a + * denial-of-service vector — so the limiter is not optional. + */ + +export const SESSION_COOKIE = 'sc_app'; + +/** How long a login lasts. Long, because the alternative is logging in during a lesson. */ +const SESSION_TTL_MS = 30 * 24 * 60 * 60_000; + +/** scrypt parameters. N=16384 is ~50ms here — slow enough to matter, fast enough to log in. */ +const SCRYPT = { N: 16_384, r: 8, p: 1, keylen: 32 }; + +/** Failed logins allowed from one address before it has to wait. */ +const MAX_ATTEMPTS = 8; +const ATTEMPT_WINDOW_MS = 15 * 60_000; + +export interface WebAuth { + /** True when a password is configured at all; without one the app is not served. */ + readonly enabled: boolean; + check(password: string, from: string): { ok: boolean; retryAfterSeconds?: number }; + mint(): string; + verify(cookieHeader: string | undefined): boolean; + cookie(value: string, options: { secure: boolean }): string; + clearCookie(options: { secure: boolean }): string; +} + +/** + * Builds the app's authenticator from the configured password. + * + * The salt is fixed rather than random because the hash is never stored: it + * lives in this process only, and a random salt would merely mean the same + * password produced a different session key on every restart — logging + * everyone out whenever the Pi reboots. + */ +export function createWebAuth(password: string | undefined): WebAuth { + if (!password) { + return { + enabled: false, + check: () => ({ ok: false }), + mint: () => '', + verify: () => false, + cookie: () => '', + clearCookie: () => '', + }; + } + + const verifier = scryptSync(password, 'schulcloud-mcp/app/verifier', SCRYPT.keylen, SCRYPT); + // A separate derivation, so a session cookie can never be used to test a + // password guess offline against the verifier. + const sessionKey = scryptSync(password, 'schulcloud-mcp/app/session', SCRYPT.keylen, SCRYPT); + const attempts = new Map(); + + return { + enabled: true, + + check(presented: string, from: string) { + const now = Date.now(); + const record = attempts.get(from); + if (record && now - record.first > ATTEMPT_WINDOW_MS) attempts.delete(from); + + const current = attempts.get(from); + if (current && current.count >= MAX_ATTEMPTS) { + return { ok: false, retryAfterSeconds: Math.ceil((ATTEMPT_WINDOW_MS - (now - current.first)) / 1000) }; + } + + const hashed = scryptSync(presented, 'schulcloud-mcp/app/verifier', SCRYPT.keylen, SCRYPT); + if (timingSafeEqual(hashed, verifier)) { + attempts.delete(from); + return { ok: true }; + } + attempts.set(from, { count: (current?.count ?? 0) + 1, first: current?.first ?? now }); + return { ok: false }; + }, + + mint(): string { + const expires = Date.now() + SESSION_TTL_MS; + // A nonce so two logins never mint the same cookie; nothing reads it + // back, it only keeps the value unique. + const nonce = randomBytes(9).toString('base64url'); + const body = `${expires}.${nonce}`; + return `${body}.${sign(body, sessionKey)}`; + }, + + verify(cookieHeader: string | undefined): boolean { + const value = readCookie(cookieHeader, SESSION_COOKIE); + if (!value) return false; + const cut = value.lastIndexOf('.'); + if (cut <= 0) return false; + const body = value.slice(0, cut); + const presented = Buffer.from(value.slice(cut + 1), 'utf8'); + const expected = Buffer.from(sign(body, sessionKey), 'utf8'); + if (presented.length !== expected.length || !timingSafeEqual(presented, expected)) return false; + const expires = Number(body.split('.')[0]); + return Number.isFinite(expires) && expires > Date.now(); + }, + + cookie(value: string, options: { secure: boolean }): string { + return [ + `${SESSION_COOKIE}=${value}`, + 'Path=/', + 'HttpOnly', + // Strict, not Lax: nothing links into this app from elsewhere, and + // Strict is what removes cross-site requests as a category. + 'SameSite=Strict', + `Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`, + options.secure ? 'Secure' : undefined, + ] + .filter(Boolean) + .join('; '); + }, + + clearCookie(options: { secure: boolean }): string { + return [ + `${SESSION_COOKIE}=`, + 'Path=/', + 'HttpOnly', + 'SameSite=Strict', + 'Max-Age=0', + options.secure ? 'Secure' : undefined, + ] + .filter(Boolean) + .join('; '); + }, + }; +} + +function sign(body: string, key: Buffer): string { + return createHmac('sha256', key).update(body).digest('base64url'); +} + +/** One cookie out of a `Cookie:` header, without a dependency. */ +export function readCookie(header: string | undefined, name: string): string | undefined { + if (!header) return undefined; + for (const part of header.split(';')) { + const eq = part.indexOf('='); + if (eq === -1) continue; + if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim(); + } + return undefined; +} + +/** + * True when the request reached us over TLS. + * + * Behind Caddy the hop to this process is plain HTTP, so the header it sets is + * the only evidence — and marking the cookie Secure on a connection that is + * not would make it vanish, which looks exactly like a broken login. + */ +export function isSecureRequest(req: Request): boolean { + const forwarded = req.get('x-forwarded-proto'); + if (forwarded) return forwarded.split(',')[0]!.trim() === 'https'; + return req.protocol === 'https'; +} + +/** Gate for the app's own pages and for `/api` when the caller is a browser. */ +export function sessionAuth(auth: WebAuth) { + return function requireSession(req: Request, res: Response, next: NextFunction): void { + if (auth.verify(req.get('cookie'))) { + next(); + return; + } + // HTML gets the login screen, fetch() gets a 401 it can act on. Answering + // a fetch with a redirect to a page would hand the caller a chunk of HTML + // it cannot use and no way to tell what went wrong. + if (req.method === 'GET' && (req.get('accept') ?? '').includes('text/html')) { + res.redirect(302, '/app/'); + return; + } + res.status(401).json({ error: 'unauthorized', message: 'Log in again.' }); + }; +} diff --git a/src/mcp/tools/notes.ts b/src/mcp/tools/notes.ts index e7c03c6..0cf29b8 100644 --- a/src/mcp/tools/notes.ts +++ b/src/mcp/tools/notes.ts @@ -5,6 +5,9 @@ import { germanDay, isCalendarDate, schoolToday } from '../../core/dates.ts'; import { filterNotes, NoteNotFound, + noteSections, + noteSubjects, + normalizeRelative, readNoteAt, readNotes, writeNote, @@ -52,8 +55,9 @@ export function registerNoteTools(server: McpServer, context: ServerContext): vo 'nor in WebUntis and is often the only record of what a teacher actually said. **Read these before ' + 'answering anything about what was covered in class, and before preparing for a test**: they say what ' + 'was emphasised, which the uploaded material does not. Filter by subject ("Deutsch", "LF07") or by ' + - 'date to get the lessons around a topic. Returns titles and first lines; get_note opens one. ' + - 'search finds notes by their contents as well.', + 'date to get the lessons around a topic — a note is often a whole school day with a heading per ' + + 'lesson, so the subject filter looks at those headings too. Returns titles and a preview; get_note ' + + 'opens one. search finds notes by their contents as well, and names the lesson it matched in.', inputSchema: { subject: z .string() @@ -78,7 +82,9 @@ export function registerNoteTools(server: McpServer, context: ServerContext): vo const terms = query ? tokenize(query) : []; const matched = filterNotes(all, { subject, since, until }).filter( - (note) => terms.length === 0 || matchesAll([note.title, note.subject, note.tags.join(' ')].join(' '), terms), + (note) => + terms.length === 0 || + matchesAll([note.title, ...noteSubjects(note), note.tags.join(' ')].join(' '), terms), ); if (matched.length === 0) { return text( @@ -115,16 +121,20 @@ export function registerNoteTools(server: McpServer, context: ServerContext): vo annotations: READ_ONLY, }, async ({ path }) => { + // A search hit inside a day's note carries `#2`; opening the note + // is the right answer, so the anchor is dropped rather than 404ing on a + // filename nobody has. + const wanted = normalizeRelative(path).replace(/#\d+$/, ''); try { - return text(renderNote(await readNoteAt(root, path))); + return text(renderNote(await readNoteAt(root, wanted))); } catch (error) { if (error instanceof NoteNotFound) { return failure( - `There is no note at "${path}". Paths come from list_notes or search and include the folder and ` + + `There is no note at "${wanted}". Paths come from list_notes or search and include the folder and ` + 'the .md ending.', ); } - return toToolError(error, `read the note "${path}"`); + return toToolError(error, `read the note "${wanted}"`); } }, ); @@ -195,18 +205,23 @@ export function registerNoteTools(server: McpServer, context: ServerContext): vo function listLine(note: NoteDoc): string { const when = note.date ? germanDay(note.date) : 'ohne Datum'; - const where = note.subject ? ` · ${note.subject}` : ''; + const subjects = noteSubjects(note); + const where = subjects.length > 0 ? ` · ${subjects.join(', ')}` : ''; const tags = note.tags.length > 0 ? ` · ${note.tags.map((tag) => `#${tag}`).join(' ')}` : ''; - const first = firstLine(note.text); - return [`- **${note.title}** — ${when}${where}${tags} \`${note.path}\``, first ? ` ${first}` : undefined] + // For a day's note the lessons are the useful preview; for a single piece of + // prose the first line is. + const sections = noteSections(note); + const preview = sections.length > 0 ? `${sections.length} Stunde(n)` : firstLine(note.text); + return [`- **${note.title}** — ${when}${where}${tags} \`${note.path}\``, preview ? ` ${preview}` : undefined] .filter(Boolean) .join('\n'); } function renderNote(note: NoteDoc): string { + const subjects = noteSubjects(note); const facts = [ note.date ? `Datum: ${germanDay(note.date)}` : undefined, - note.subject ? `Fach: ${note.subject}` : undefined, + subjects.length > 0 ? `${subjects.length > 1 ? 'Fächer' : 'Fach'}: ${subjects.join(', ')}` : undefined, note.tags.length > 0 ? `Tags: ${note.tags.join(', ')}` : undefined, note.courseId ? `Kurs: \`${note.courseId}\`` : undefined, ].filter(Boolean); diff --git a/src/mcp/tools/search.ts b/src/mcp/tools/search.ts index 677378d..4a71889 100644 --- a/src/mcp/tools/search.ts +++ b/src/mcp/tools/search.ts @@ -166,8 +166,13 @@ function nextStep(hit: SearchResult): string { ? ` → \`fs_read\` with path \`${fsPath}\`` : ` → \`fs_read\` with fileId \`${hit.nodeId}\` and name \`${hit.title}\``; } - // A note is addressed by its path, not by an id. - if (hit.kind === 'note') return ` → \`get_note\` with path \`${hit.nodeId}\``; + // A note is addressed by its path, not by an id — and a lesson inside a day's + // note is reached by opening the note, since `#3` is an index into this + // generation and means nothing to get_note. + if (hit.kind === 'note') { + const notePath = typeof hit.meta?.notePath === 'string' ? hit.meta.notePath : hit.nodeId.replace(/#\d+$/, ''); + return ` → \`get_note\` with path \`${notePath}\``; + } if (hit.kind === 'untis') { const periodId = hit.meta?.periodId; return ` → \`untis_lesson_topics\` with periodId \`${typeof periodId === 'number' ? periodId : hit.nodeId}\``; @@ -192,9 +197,10 @@ function placeOf(hit: SearchResult): string { if (hit.kind === 'note') { const date = typeof hit.meta?.date === 'string' ? formatDate(hit.meta.date) : undefined; const subject = typeof hit.meta?.subject === 'string' ? hit.meta.subject : undefined; + const heading = typeof hit.meta?.heading === 'string' ? hit.meta.heading : undefined; // Named as the user's own writing, so it is never quoted as if the school - // had published it. - return ['my own note', subject, date].filter(Boolean).join(', '); + // had published it. A lesson within a day's note says which lesson. + return ['my own note', subject ?? heading, date].filter(Boolean).join(', '); } if (hit.kind === 'untis') { const date = typeof hit.meta?.date === 'string' ? formatDate(hit.meta.date) : undefined; diff --git a/src/store/store.ts b/src/store/store.ts index cdc303f..d3ce8ad 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import type { CrawledFile, Snapshot } from '../core/crawl.ts'; -import { noteSearchText } from '../core/notes.ts'; +import { noteSearchText, noteSections } from '../core/notes.ts'; import { mirrorPath } from '../core/paths.ts'; import { lessonLogText } from '../core/untis-history.ts'; import { connect, migrate, type Db } from './db.ts'; @@ -654,21 +654,50 @@ export function snapshotToNodes(snapshot: Snapshot): StoredNode[] { // most notes sit outside any course — which is also what makes them survive // a per-course crawl's carry-forward untouched. for (const note of snapshot.notes ?? []) { - nodes.push({ - kind: 'note', - nodeId: note.path, + const common = { courseId: note.courseId ?? null, - title: note.title, - body: noteSearchText(note), - path: `Notizen/${note.path}`, meta: { + notePath: note.path, ...(note.date ? { date: note.date } : {}), - ...(note.subject ? { subject: note.subject } : {}), ...(note.source ? { source: note.source } : {}), tags: note.tags, modifiedAt: note.modifiedAt, - bytes: note.bytes, }, + }; + + // A note written as one school day, with a heading per lesson, is indexed + // per lesson: one node for "Deutsch, 15.09." rather than one for "Monday". + // Indexed whole, every hit in it would read "my note, Monday" and lose the + // only thing that makes it findable — and "what did we do in Deutsch" + // would match a note whose other five lessons were something else. + const sections = noteSections(note); + if (sections.length > 0) { + for (const [index, section] of sections.entries()) { + nodes.push({ + ...common, + kind: 'note', + // The heading's position, not its text: renaming a heading should + // read as an edit, and two lessons of the same subject on one day + // must not collide. + nodeId: `${note.path}#${index}`, + title: section.subject ? `${section.subject} — ${note.date ?? note.title}` : section.heading, + body: [section.heading, section.text].filter(Boolean).join('\n'), + path: `Notizen/${note.path} → ${section.heading}`, + meta: { ...common.meta, heading: section.heading, ...(section.subject ? { subject: section.subject } : {}) }, + digest: digestOf([section.heading, section.text]), + }); + } + continue; + } + + nodes.push({ + ...common, + kind: 'note', + nodeId: note.path, + title: note.title, + body: noteSearchText(note), + path: `Notizen/${note.path}`, + meta: { ...common.meta, ...(note.subject ? { subject: note.subject } : {}), bytes: note.bytes }, // The file's mtime is deliberately not in the digest: a sync tool that // rewrites a file byte-for-byte must not show up as a changed note. digest: digestOf([note.title, note.text, note.subject ?? '', note.date ?? '', note.tags]), diff --git a/test/day-note.test.ts b/test/day-note.test.ts new file mode 100644 index 0000000..e3b9990 --- /dev/null +++ b/test/day-note.test.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { dayLessons, dayNoteSkeleton, dayNoteTitle, lessonHeading, missingHeadings } from '../src/core/day-note.ts'; +import { subjectFromHeading } from '../src/core/notes.ts'; +import type { UntisLesson, UntisTimetable } from '../src/core/untis.ts'; + +function lesson(overrides: Partial & { periodId: number }): UntisLesson { + return { + lessonId: 1, + date: '2026-09-18', + start: '08:00', + end: '08:45', + statuses: ['REGULAR'], + cancelled: false, + changed: false, + subjects: [{ name: 'DE', longName: 'Deutsch' }], + teachers: [{ name: 'MEI', longName: 'Meier' }], + rooms: [{ name: '204' }], + classes: [], + replaced: { subjects: [], teachers: [], rooms: [] }, + notes: {}, + homework: [], + online: false, + ...overrides, + }; +} + +function timetable(lessons: UntisLesson[]): UntisTimetable { + return { from: '2026-09-18', to: '2026-09-18', days: [{ date: '2026-09-18', lessons, holidays: [] }] }; +} + +describe('dayNoteTitle', () => { + it('is the weekday and the date, as a person would write it', () => { + assert.equal(dayNoteTitle('2026-09-18'), 'Freitag, 18.09.2026'); + }); +}); + +describe('lessonHeading', () => { + it('leads with the subject, then the time, teacher and room', () => { + assert.equal(lessonHeading(lesson({ periodId: 1 }), 0), '1. Deutsch — 08:00–08:45 · MEI · R 204'); + }); + + it('marks a substitution, because the teacher is not the usual one', () => { + assert.match(lessonHeading(lesson({ periodId: 1, changed: true }), 0), /Vertretung$/); + }); + + it('survives a period with no subject at all', () => { + assert.match(lessonHeading(lesson({ periodId: 1, subjects: [] }), 2), /^3\. Stunde — /); + }); + + it('writes a heading subjectFromHeading reads back — the loop that makes lessons searchable', () => { + // These two have to agree or a day's notes index under nothing: the page + // writes the heading, the indexer reads the subject out of it again. + for (const [index, entry] of [lesson({ periodId: 1 }), lesson({ periodId: 2, changed: true })].entries()) { + assert.equal(subjectFromHeading(lessonHeading(entry, index)), 'Deutsch'); + } + }); +}); + +describe('dayLessons', () => { + it('leaves out a cancelled period, which taught nothing', () => { + const lessons = dayLessons( + timetable([lesson({ periodId: 1 }), lesson({ periodId: 2, cancelled: true, start: '08:50', end: '09:35' })]), + '2026-09-18', + ); + assert.deepEqual(lessons.map((entry) => entry.periodId), [1]); + }); + + it('keeps a substitution, which did happen', () => { + const lessons = dayLessons(timetable([lesson({ periodId: 1, changed: true })]), '2026-09-18'); + assert.equal(lessons[0]?.changed, true); + }); + + it('is empty for a day the timetable does not cover', () => { + assert.deepEqual(dayLessons(timetable([lesson({ periodId: 1 })]), '2026-09-19'), []); + }); +}); + +describe('dayNoteSkeleton', () => { + it('is a heading per lesson with room to write under each', () => { + const text = dayNoteSkeleton(dayLessons(timetable([lesson({ periodId: 1 }), lesson({ periodId: 2, start: '08:50', end: '09:35' })]), '2026-09-18')); + assert.equal(text.match(/^## /gm)?.length, 2); + assert.match(text, /^## 1\. Deutsch/m); + }); + + it('is empty when there are no lessons, rather than a lone heading', () => { + assert.equal(dayNoteSkeleton([]), ''); + }); +}); + +describe('missingHeadings', () => { + const lessons = dayLessons( + timetable([lesson({ periodId: 1 }), lesson({ periodId: 2, start: '08:50', end: '09:35' })]), + '2026-09-18', + ); + + it('is empty when the note already has them', () => { + assert.deepEqual(missingHeadings(dayNoteSkeleton(lessons), lessons), []); + }); + + it('names the lesson a note started early does not have yet', () => { + const started = '## 1. Deutsch — 08:00–08:45 · MEI · R 204\n\nErörterung.\n'; + assert.deepEqual(missingHeadings(started, lessons).map((entry) => entry.periodId), [2]); + }); + + it('recognises a heading the person shortened', () => { + // "2. Deutsch" is still the second period; adding it again would give the + // day two of them. + assert.deepEqual(missingHeadings('## 1. Kurz\n## 2. Auch kurz\n', lessons), []); + }); + + it('is everything for an empty note', () => { + assert.equal(missingHeadings('', lessons).length, 2); + }); +}); diff --git a/test/notes.test.ts b/test/notes.test.ts index 0a32cdc..9fb541e 100644 --- a/test/notes.test.ts +++ b/test/notes.test.ts @@ -4,8 +4,14 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it } from 'node:test'; import { + dayNotePath, filterNotes, + NoteConflict, NoteNotFound, + noteSections, + noteSubjects, + replaceNote, + subjectFromHeading, notePathFor, parseNote, readNoteAt, @@ -201,6 +207,142 @@ describe('writeNote', () => { }); }); +describe('splitFrontmatter: block lists', () => { + it('reads tags written as indented "- item" lines, which is how editors write them', () => { + // Obsidian and most YAML front ends write a list this way; reading only + // the inline form silently dropped every tag such an editor had written. + const { front } = splitFrontmatter('---\ntitle: T\ntags:\n - klausur\n - aufsatz\n---\nx'); + assert.deepEqual(front.tags, ['klausur', 'aufsatz']); + assert.equal(front.title, 'T'); + }); + + it('stops the list at the next key', () => { + const { front } = splitFrontmatter('---\ntags:\n - eins\nsubject: Deutsch\n---\nx'); + assert.deepEqual(front.tags, ['eins']); + assert.equal(front.subject, 'Deutsch'); + }); +}); + +describe('a note per school day', () => { + const day = parseNote( + '2026/2026-09-18.md', + [ + '## 1. Deutsch — 08:00–08:45 · MEI', + '', + 'Erörterung: These, Argument, Fazit.', + '', + '### Aufbau', + '', + '- Gegenargument nicht vergessen', + '', + '## 2. LF07 — 08:50–09:35 · Sb', + '', + '/24 = 254 nutzbare Adressen', + ].join('\n'), + STAMP, + ); + + it('does not take the year folder for a subject', () => { + // "2026/" is a filing scheme, not a lesson. + assert.equal(day.subject, undefined); + }); + + it('splits into one section per lesson', () => { + assert.deepEqual(noteSections(day).map((section) => section.subject), ['Deutsch', 'LF07']); + }); + + it('keeps subheadings inside their lesson', () => { + const first = noteSections(day)[0]!; + assert.match(first.text, /### Aufbau/); + assert.doesNotMatch(first.text, /LF07/); + }); + + it('reports every subject the day covers', () => { + assert.deepEqual(noteSubjects(day), ['Deutsch', 'LF07']); + }); + + it('is found by a subject filter, which only its headings know', () => { + assert.equal(filterNotes([day], { subject: 'lf07' }).length, 1); + assert.equal(filterNotes([day], { subject: 'Mathe' }).length, 0); + }); + + it('does not split on a ## inside a fenced code block', () => { + const note = parseNote('2026/2026-09-18.md', '## Info\n\n```\n## nicht eine Stunde\n```\n', STAMP); + assert.equal(noteSections(note).length, 1); + }); + + it('has no sections when it is one piece of prose, as an imported note is', () => { + assert.deepEqual(noteSections(parseNote('Deutsch/2026-09-15 A.md', 'Nur Text.', STAMP)), []); + }); +}); + +describe('subjectFromHeading', () => { + it('reads the subject out of every shape the page and a person write', () => { + for (const [heading, expected] of [ + ['1. Deutsch — 08:00–08:45 · MEI · R 204', 'Deutsch'], + ['2) LF07', 'LF07'], + ['Deutsch', 'Deutsch'], + ['08:00 Deutsch', 'Deutsch'], + ['3. Mathe (Vertretung)', 'Mathe'], + ] as const) { + assert.equal(subjectFromHeading(heading), expected, heading); + } + }); + + it('names no subject rather than a wrong one', () => { + for (const heading of ['1.', '08:00–08:45', '—', '###']) { + assert.equal(subjectFromHeading(heading), undefined, heading); + } + }); +}); + +describe('dayNotePath', () => { + it('files a day under its year', () => { + assert.equal(dayNotePath('2026-09-18'), '2026/2026-09-18.md'); + }); +}); + +describe('replaceNote', () => { + it('overwrites, which is what saving from an editor means', async () => { + const dir = await root(); + await replaceNote(dir, dayNotePath('2026-09-18'), { title: 'Freitag', text: 'eins', date: '2026-09-18' }); + const note = await replaceNote(dir, dayNotePath('2026-09-18'), { title: 'Freitag', text: 'zwei', date: '2026-09-18' }); + assert.equal(note.text, 'zwei'); + assert.equal((await readNotes(dir)).length, 1, 'saving twice is one note, not two'); + }); + + it('refuses a save that would clobber a version the editor never saw', async () => { + // The notes folder is synced and open in more than one place; a phone must + // not silently win over a laptop. + const dir = await root(); + const first = await replaceNote(dir, 'x.md', { title: 'X', text: 'vom Laptop' }); + await assert.rejects( + () => replaceNote(dir, 'x.md', { title: 'X', text: 'vom Handy' }, { expectedModifiedAt: '2020-01-01T00:00:00.000Z' }), + NoteConflict, + ); + assert.equal((await readNoteAt(dir, 'x.md')).text, 'vom Laptop', 'the refused save changed nothing'); + assert.ok(first.modifiedAt); + }); + + it('accepts a save carrying the modification time it loaded', async () => { + const dir = await root(); + const loaded = await replaceNote(dir, 'x.md', { title: 'X', text: 'eins' }); + const saved = await replaceNote(dir, 'x.md', { title: 'X', text: 'zwei' }, { expectedModifiedAt: loaded.modifiedAt }); + assert.equal(saved.text, 'zwei'); + }); + + it('creates the note when there is none, with nothing to clash against', async () => { + const dir = await root(); + const note = await replaceNote(dir, dayNotePath('2026-09-18'), { title: 'Freitag', text: 'neu' }, { expectedModifiedAt: '2020-01-01T00:00:00.000Z' }); + assert.equal(note.text, 'neu'); + }); + + it('cannot be steered out of the notes root', async () => { + const dir = await root(); + await assert.rejects(() => replaceNote(dir, '../escape.md', { title: 'X', text: 'x' }), /traversal/); + }); +}); + describe('filterNotes', () => { const notes = [ parseNote('Deutsch/2026-09-15 A.md', 'a', STAMP), diff --git a/test/web-auth.test.ts b/test/web-auth.test.ts new file mode 100644 index 0000000..4024e00 --- /dev/null +++ b/test/web-auth.test.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { createWebAuth, isSecureRequest, readCookie, SESSION_COOKIE } from '../src/http/web-auth.ts'; + +/** + * The app's login. scrypt is deliberately slow, so these share one authenticator + * rather than building one per test. + */ +const PASSWORD = 'ein-sehr-langes-testpasswort'; +const auth = createWebAuth(PASSWORD); + +function cookieHeader(value: string): string { + return `${SESSION_COOKIE}=${value}`; +} + +describe('createWebAuth without a password', () => { + it('is disabled, and nothing it returns opens anything', () => { + // The app is not served at all in this case; the object exists so callers + // need no branch, and every answer it gives is "no". + const off = createWebAuth(undefined); + assert.equal(off.enabled, false); + assert.equal(off.check(PASSWORD, '::1').ok, false); + assert.equal(off.verify(cookieHeader('anything')), false); + }); +}); + +describe('password check', () => { + it('accepts the password and rejects everything else', () => { + assert.equal(auth.check(PASSWORD, 'a').ok, true); + assert.equal(auth.check(PASSWORD + 'x', 'a').ok, false); + assert.equal(auth.check('', 'a').ok, false); + }); + + it('locks an address out after repeated failures', () => { + const from = 'brute-force'; + let blocked; + for (let attempt = 0; attempt < 12; attempt++) { + blocked = auth.check('wrong', from); + if (blocked.retryAfterSeconds !== undefined) break; + } + assert.ok(blocked?.retryAfterSeconds, 'expected a lockout with a retry hint'); + // And the lockout holds even for the *right* password, or it would be no + // lockout at all — the attacker only has to guess it once. + assert.equal(auth.check(PASSWORD, from).ok, false); + }); + + it('counts per address, so one attacker cannot lock the user out', () => { + assert.equal(auth.check(PASSWORD, 'somebody-else').ok, true); + }); + + it('forgets the failures once a login succeeds', () => { + const from = 'recovers'; + auth.check('wrong', from); + auth.check('wrong', from); + assert.equal(auth.check(PASSWORD, from).ok, true); + assert.equal(auth.check(PASSWORD, from).ok, true); + }); +}); + +describe('session cookies', () => { + it('mints a cookie it accepts back', () => { + assert.equal(auth.verify(cookieHeader(auth.mint())), true); + }); + + it('mints a different value every time', () => { + assert.notEqual(auth.mint(), auth.mint()); + }); + + it('refuses a tampered signature', () => { + const value = auth.mint(); + assert.equal(auth.verify(cookieHeader(`${value.slice(0, -1)}${value.at(-1) === 'A' ? 'B' : 'A'}`)), false); + }); + + it('refuses an extended expiry, which is the point of signing it', () => { + const value = auth.mint(); + const signature = value.slice(value.lastIndexOf('.') + 1); + assert.equal(auth.verify(cookieHeader(`${Date.now() + 10 ** 12}.nonce.${signature}`)), false); + }); + + it('refuses an expired cookie even with a good signature', () => { + // Signed by this key, but for a moment that has passed. + const body = `${Date.now() - 1000}.nonce`; + const fresh = auth.mint(); + const shape = `${body}.${fresh.slice(fresh.lastIndexOf('.') + 1)}`; + assert.equal(auth.verify(cookieHeader(shape)), false); + }); + + it('refuses nonsense and an absent cookie', () => { + for (const value of ['', 'x', 'a.b', '...']) assert.equal(auth.verify(cookieHeader(value)), false, value); + assert.equal(auth.verify(undefined), false); + assert.equal(auth.verify('other=1'), false); + }); + + it('is not accepted by an authenticator built from a different password', () => { + // Changing the password logs everyone out, because the signing key is + // derived from it. + const other = createWebAuth('ein-ganz-anderes-passwort'); + assert.equal(other.verify(cookieHeader(auth.mint())), false); + }); + + it('is HttpOnly and SameSite=Strict, and Secure only over TLS', () => { + const secure = auth.cookie('v', { secure: true }); + assert.match(secure, /HttpOnly/); + assert.match(secure, /SameSite=Strict/); + assert.match(secure, /Secure/); + // Marking it Secure on a plain connection makes it vanish, which looks + // exactly like a broken login. + assert.doesNotMatch(auth.cookie('v', { secure: false }), /Secure/); + }); + + it('clears with an immediate expiry', () => { + assert.match(auth.clearCookie({ secure: true }), /Max-Age=0/); + }); +}); + +describe('readCookie', () => { + it('finds one cookie among several', () => { + assert.equal(readCookie('a=1; sc_app=wanted; b=2', 'sc_app'), 'wanted'); + }); + + it('does not match a name that merely ends the same way', () => { + assert.equal(readCookie('not_sc_app=no', 'sc_app'), undefined); + }); + + it('is undefined for no header', () => { + assert.equal(readCookie(undefined, 'sc_app'), undefined); + }); +}); + +describe('isSecureRequest', () => { + const request = (headers: Record, protocol = 'http') => + ({ get: (name: string) => headers[name.toLowerCase()], protocol }) as never; + + it('trusts the forwarded protocol, which is all there is behind a proxy', () => { + assert.equal(isSecureRequest(request({ 'x-forwarded-proto': 'https' })), true); + assert.equal(isSecureRequest(request({ 'x-forwarded-proto': 'http' })), false); + }); + + it('reads only the first hop of a chain', () => { + assert.equal(isSecureRequest(request({ 'x-forwarded-proto': 'https, http' })), true); + }); + + it('falls back to the connection when nothing forwarded it', () => { + assert.equal(isSecureRequest(request({}, 'https')), true); + assert.equal(isSecureRequest(request({})), false); + }); +});