Write the notes in an app, a school day at a time
The notes existed but there was nowhere to write them: a CLI command on a laptop, a tool call through Claude, or a file in a Docker volume. None of those is reachable from a phone in a lesson, which is where notes are actually taken. So: `/app`, served only when WEB_PASSWORD is set. A login, the day's notes, and a settings page for the Schulcloud token — the one surface here meant for a person rather than a program. The shape follows how the notes are written: one note per school day, one `##` heading per lesson, prose and lists and tables beneath. That turns out to be the design decision that matters, twice over. First, it is what lets WebUntis earn its keep. Opening a day with no note fills in that day's lessons — numbered, with times, teacher and room, cancellations dropped and substitutions marked. Retyping the timetable is exactly the work the second upstream exists to avoid, and "Stunden ergänzen" tops up a note started before the day ended without touching what is already written. Second, it changes how notes are indexed. A day note is indexed per lesson, not whole: search answers "my own note, Deutsch, 18.09.2026" rather than "my own note, Friday", and `list_notes subject=Deutsch` finds a day whose frontmatter names no subject at all. Indexed whole, every hit would read as a weekday and "what did we do in Deutsch" would match notes whose other five lessons were something else. `lessonHeading` and `subjectFromHeading` are a loop — the app writes the heading, the indexer reads the subject back out — and a test holds them to it. Notes taken in a lesson cannot be retaken, so the editor is built around not losing them: autosave, every keystroke mirrored to local storage, a save when the phone locks, and a fallback to the local copy when the request never arrives. A save that would overwrite a version the editor never saw is refused and the choice handed back — the notes folder is synced and open in more than one place, and a phone must not silently win over a laptop. `replaceNote` is separate from `writeNote` for that reason: never-overwrite is right for `add_note` and exactly wrong for an editor. WEB_PASSWORD is the first credential here a human types, so it is the first that can be guessed: scrypt at startup, never stored or compared in the clear, per-address rate limiting — which is not decoration, since the scrypt cost is itself a denial-of-service vector without it. The session is a signed HttpOnly SameSite=Strict cookie whose key is derived from the password, so changing it logs everyone out and there is no second secret to keep. It opens /api, because a session is the user, and never /mcp, because nothing in a browser speaks MCP. Also here, because the app made them matter: frontmatter now reads the indented `- item` list form editors write, so an Obsidian vault round-trips its tags; and a four-digit folder is a filing scheme, not a subject, so `2026/` does not file a school year under one. 357 tests; 106/107 smoke against the local instance, the one failure being the H5P service that instance does not run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
14
.env.example
14
.env.example
@@ -100,6 +100,20 @@ DATABASE_URL=postgresql://schulcloud:schulcloud@postgres:5432/schulcloud
|
|||||||
# from somewhere else and should have exactly one writer.
|
# from somewhere else and should have exactly one writer.
|
||||||
# NOTES_READONLY=false
|
# 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)
|
# WebUntis (optional — the timetable, which Schulcloud does not hold)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
48
CLAUDE.md
48
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.
|
than one that says which said what.
|
||||||
|
|
||||||
Three entry points over one core:
|
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/stdio.ts` — stdio, for local Claude Code / Desktop use.
|
||||||
- `src/bin/cli.ts` — the `schulcloud` CLI, which talks to the HTTP server, never
|
- `src/bin/cli.ts` — the `schulcloud` CLI, which talks to the HTTP server, never
|
||||||
to Schulcloud.
|
to Schulcloud.
|
||||||
@@ -61,8 +61,8 @@ exercised and can never touch real notes. The degradation paths are supported
|
|||||||
modes, not fallbacks nobody exercises.
|
modes, not fallbacks nobody exercises.
|
||||||
|
|
||||||
The check counts are a tripwire, so re-measure them rather than trusting this
|
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
|
line after a change: **106/107 against the local instance** on 2026-09-19 (the
|
||||||
failure is the H5P service, which that instance does not run). The live counts
|
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
|
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
|
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.
|
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
|
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
|
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.
|
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
|
- **`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
|
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
|
before the first crawl and while Postgres is down. **The one thing anything
|
||||||
here writes** — see Invariants. `docs/NOTES.md` is the guide.
|
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 `/<secret>/mcp` for clients without
|
open `/api`. Besides those: the optional `/<secret>/mcp` for clients without
|
||||||
headers (`MCP_PATH_SECRET`), and `/token`, a page that PUTs a fresh token to
|
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.
|
`/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/`** — crawl generations, identity diffs, `german` + `pg_trgm` FTS.
|
||||||
`Store.open` returns `undefined` when Postgres is down; callers degrade.
|
`Store.open` returns `undefined` when Postgres is down; callers degrade.
|
||||||
- **`indexer/`** — crawl → persist → mirror bytes → extract text → index.
|
- **`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
|
own index and mirror, and `PUT /api/token` only to the server's own token — every
|
||||||
upstream call they make is still a GET.
|
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
|
**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
|
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
|
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.
|
user explicitly asking for one and understanding this.
|
||||||
|
|
||||||
**Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the
|
**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
|
`UNTIS_SECRET` authenticates as the user in WebUntis and outlives every other
|
||||||
credential here, since it does not expire. None belongs in
|
credential here, since it does not expire. None belongs in
|
||||||
logs, error messages, or tool output. `.env` is git-ignored — keep it that way.
|
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
|
`.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
|
`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),
|
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`, `UNTIS_HISTORY_DAYS` and `WEB_PASSWORD` (the app at `/app`);
|
||||||
`NOTES_DIR`. See `.env.example` for the
|
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`,
|
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
|
no restart. `npm run probe` and `schulcloud token` report the clocks: days until
|
||||||
hard expiry and the session budget.
|
hard expiry and the session budget.
|
||||||
|
|||||||
17
README.md
17
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
|
all three prompts read it alongside everything else — including a migration path
|
||||||
out of Apple Notes. See [docs/NOTES.md](docs/NOTES.md).
|
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:
|
**Three ready-made prompts**, in German because the school is:
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
@@ -205,7 +220,7 @@ src/
|
|||||||
store/ Postgres: crawl generations, diffs, full-text search
|
store/ Postgres: crawl generations, diffs, full-text search
|
||||||
indexer/ crawl → persist → mirror bytes → extract text → index
|
indexer/ crawl → persist → mirror bytes → extract text → index
|
||||||
mcp/ MCP server, tools, resources and prompts
|
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
|
cli/ CLI config, API client, sync engine
|
||||||
bin/ http, stdio and cli entry points
|
bin/ http, stdio and cli entry points
|
||||||
docs/ API findings, auth, deployment, CLI, notes, roadmap
|
docs/ API findings, auth, deployment, CLI, notes, roadmap
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ mcp.example.org {
|
|||||||
|
|
||||||
# `schulcloud-mcp` is the Compose service name; Docker's embedded DNS
|
# `schulcloud-mcp` is the Compose service name; Docker's embedded DNS
|
||||||
# resolves it on the shared network. No host port is published. One proxy
|
# 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 {
|
reverse_proxy schulcloud-mcp:8080 {
|
||||||
# MCP's Streamable HTTP transport keeps a server-sent-events channel
|
# MCP's Streamable HTTP transport keeps a server-sent-events channel
|
||||||
# open for server-initiated messages. Without flush_interval -1 Caddy
|
# open for server-initiated messages. Without flush_interval -1 Caddy
|
||||||
|
|||||||
39
docs/AUTH.md
39
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
|
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.
|
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
|
## Blast radius
|
||||||
|
|
||||||
Every path in this server is a `GET`, including the `api_get` escape hatch,
|
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
|
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
|
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
|
the connector token, the secret MCP path, or the app password — could read this
|
||||||
Schulcloud data; they could not
|
account's Schulcloud data; they could not
|
||||||
post, submit, delete, or otherwise act as the user. With `MCP_AUTH_TOKEN` they
|
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
|
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
|
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
|
has. Keep it that way — adding a single write tool would change that property
|
||||||
entirely.
|
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.
|
||||||
|
|||||||
@@ -216,6 +216,32 @@ for a deployment:
|
|||||||
time-based code; a drifting clock is refused with "invalid client time", which
|
time-based code; a drifting clock is refused with "invalid client time", which
|
||||||
the tools report in those words.
|
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
|
## Replacing the Schulcloud token
|
||||||
|
|
||||||
The token lasts 30 days at most and can only come from a browser login (see
|
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
|
1. Log in to Schulcloud in a **private window** and copy the `jwt` cookie's
|
||||||
value (DevTools → Application → Cookies).
|
value (DevTools → Application → Cookies).
|
||||||
2. Either run `schulcloud token set` and paste it, or open
|
2. Either run `schulcloud token set` and paste it, open the app's
|
||||||
`https://mcp.example.org/token` and paste it together with `MCP_AUTH_TOKEN`.
|
**Einstellungen** tab, or open `https://mcp.example.org/token` and paste it
|
||||||
|
together with `MCP_AUTH_TOKEN`.
|
||||||
3. **Close the private window.**
|
3. **Close the private window.**
|
||||||
|
|
||||||
The server checks the token with Schulcloud first — right account, not
|
The server checks the token with Schulcloud first — right account, not
|
||||||
|
|||||||
246
docs/NOTES.md
246
docs/NOTES.md
@@ -7,113 +7,142 @@ whatever you write down during the lesson, and until now it lived somewhere no
|
|||||||
agent could read.
|
agent could read.
|
||||||
|
|
||||||
This is the third source: a directory of Markdown files the server reads,
|
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/
|
NOTES_DIR/
|
||||||
|
2026/
|
||||||
|
2026-09-18.md ← Freitag, one heading per lesson
|
||||||
|
2026-09-21.md
|
||||||
Deutsch/
|
Deutsch/
|
||||||
2026-09-15 Erörterung.md
|
2026-09-15 Erörterung.md ← a single-subject note, e.g. from the import
|
||||||
2026-09-22 Sprachanalyse.md
|
|
||||||
LF07/
|
|
||||||
2026-09-16 Subnetting.md
|
|
||||||
Allgemein/
|
|
||||||
2026-09-18 Elternabend.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Why files
|
A day note looks like this, and the shape is load-bearing:
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
---
|
---
|
||||||
title: Erörterung — Aufbau
|
title: Freitag, 18.09.2026
|
||||||
date: 2026-09-15
|
date: 2026-09-18
|
||||||
subject: Deutsch
|
source: notes-page
|
||||||
tags: [klausur, aufsatz]
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 1. Deutsch — 08:00–08:45 · MEI · R 204
|
||||||
|
|
||||||
Dreischritt: These, Argument mit Beleg, Fazit.
|
Dreischritt: These, Argument mit Beleg, Fazit.
|
||||||
|
|
||||||
Frau Meier betont: das **Gegenargument** darf nicht fehlen, sonst gibt es
|
### Aufbau
|
||||||
Abzug — kam letztes Jahr in der Arbeit dran.
|
|
||||||
|
- 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 |
|
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.
|
||||||
| `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` |
|
|
||||||
|
|
||||||
`date` also accepts `15.09.2026`, and `fach:` works as a German spelling of
|
## The app
|
||||||
`subject:`. Anything else in the block is kept but not interpreted.
|
|
||||||
|
|
||||||
**The file's modification time is never used as the date.** An import writes
|
`/app` is a small web app: a login, the day's notes, and a settings page. It is
|
||||||
every note today; dating a year of lessons "today" would make the whole store
|
served only when `WEB_PASSWORD` is set.
|
||||||
useless for revision, which is most of what it is for.
|
|
||||||
|
|
||||||
## Reading them
|
```
|
||||||
|
https://mcp.example.org/app/
|
||||||
|
```
|
||||||
|
|
||||||
| Tool | Answers |
|
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
|
||||||
| `list_notes` | "what did I write down in Deutsch before the test" — filter by subject or date |
|
thing I open in a free period".
|
||||||
| `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 |
|
|
||||||
|
|
||||||
The `pruefungsvorbereitung`, `zusammenfassung` and `tagesvorbereitung` prompts
|
**Notizen** is one screen: the day, `‹ ›` to move between days, and the editor.
|
||||||
consult them on their own, and are told to say when a note disagrees with the
|
|
||||||
uploaded material rather than quietly preferring one.
|
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
# from the command line, text piped in
|
# the command line, text piped in
|
||||||
pbpaste | schulcloud note add --title "Subnetting" --subject LF07
|
pbpaste | schulcloud note add --title "Subnetting" --subject LF07
|
||||||
|
|
||||||
# from an editor, or anything else that writes files
|
# an editor, or anything else that writes files
|
||||||
$EDITOR "$NOTES_DIR/LF07/2026-09-16 Subnetting.md"
|
$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"
|
# "halt fest: Gegenargument nicht vergessen, kam letztes Jahr dran"
|
||||||
# → add_note, subject Deutsch, today's date
|
# → 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
|
`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
|
already written **for that subject and that day** rather than starting a second
|
||||||
is the right choice while a lesson is running: notes accumulate in one file the
|
one — so "note this down too" mid-lesson lands in the note that is already open,
|
||||||
way they do on paper.
|
whatever its title.
|
||||||
|
|
||||||
**This is the only thing in this server that writes anything.** It writes into
|
## What may write, and where
|
||||||
the notes directory and nowhere else: every path component goes through
|
|
||||||
`safeComponent` and the result through `resolveWithin`, the same two functions
|
**The notes directory is the only thing this server writes to.** Not Schulcloud,
|
||||||
that stop a hostile filename escaping the file mirror, so a note titled
|
not WebUntis — those stay read-only, strictly, and that has not changed.
|
||||||
`../../.ssh/authorized_keys` becomes a filename. Schulcloud and WebUntis stay
|
|
||||||
strictly read-only — see the invariants in `CLAUDE.md`. Set `NOTES_READONLY=1`
|
Every path component goes through `safeComponent` and the result through
|
||||||
to refuse writes entirely, which is right when the notes are synced in from
|
`resolveWithin`, the same two functions that stop a hostile Schulcloud filename
|
||||||
somewhere else and should have exactly one writer.
|
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
|
## 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"); without it every note comes back empty. `--folder Deutsch` exports one
|
||||||
Notes folder.
|
Notes folder.
|
||||||
|
|
||||||
Then convert and import. Look at it first:
|
Then look at what it would do, and do it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
schulcloud note import notes.ndjson --dry-run
|
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-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.
|
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:
|
What it does with each note:
|
||||||
|
|
||||||
```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:
|
|
||||||
|
|
||||||
- **The Notes folder becomes the subject** — its last segment, so `Schule/Deutsch`
|
- **The Notes folder becomes the subject** — its last segment, so `Schule/Deutsch`
|
||||||
is `Deutsch`. Notes' own default folders (`Notizen`, `Recently Deleted`) are
|
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.
|
- **Locked notes cannot be read at all** and are listed by name at the end.
|
||||||
Unlock them in Notes and export again.
|
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
|
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
|
importer cannot tell an edited note from a new one with the same title. Import
|
||||||
once, then keep writing in the new place.
|
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
|
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:
|
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
|
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
|
`/home/pi/Notizen`. New files are picked up by the next crawl; nothing has to be
|
||||||
up by the next crawl; nothing has to be told about them.
|
told about them, and the app edits the same files.
|
||||||
|
|
||||||
Without any of that, `schulcloud note add` and `add_note` still work — they go
|
Frontmatter is a small YAML subset — scalars and lists, in either the inline
|
||||||
over the same authenticated `/api` as everything else the CLI does.
|
`[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
|
## What the crawl does with them
|
||||||
|
|
||||||
A **full** crawl reads the directory and indexes every note: title, subject,
|
A **full** crawl reads the directory and indexes every note — one entry per
|
||||||
tags and body, under the kind `note`, with its path as its id. A **per-course**
|
lesson for a day note, one for the whole note otherwise, under the kind `note`.
|
||||||
refresh leaves them alone — notes belong to the account, not to a course — and
|
A **per-course** refresh leaves them alone, and the store carries the previous
|
||||||
the store carries the previous generation's rows forward, so a per-course crawl
|
generation's rows forward, so a per-course crawl never looks like the notes were
|
||||||
never looks like the notes were deleted.
|
deleted.
|
||||||
|
|
||||||
Notes are diffed by content, not by modification time, so a sync tool that
|
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.
|
rewrites a file byte-for-byte does not show up in `what_changed` as an edit.
|
||||||
|
|||||||
@@ -8,7 +8,13 @@
|
|||||||
*/
|
*/
|
||||||
import { cp, mkdir } from 'node:fs/promises';
|
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) {
|
for (const [from, to] of assets) {
|
||||||
await mkdir(to, { recursive: true });
|
await mkdir(to, { recursive: true });
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ process.env.STATE_DIR = STATE_DIR;
|
|||||||
// an empty directory is also the only way to assert the empty case.
|
// an empty directory is also the only way to assert the empty case.
|
||||||
const NOTES_DIR = await mkdtemp(join(tmpdir(), 'schulcloud-smoke-notes-'));
|
const NOTES_DIR = await mkdtemp(join(tmpdir(), 'schulcloud-smoke-notes-'));
|
||||||
process.env.NOTES_DIR = NOTES_DIR;
|
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.
|
// The app is bound by this script on an ephemeral port, so config.port is unused.
|
||||||
|
|
||||||
const config = loadConfig();
|
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 ==');
|
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 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);
|
check('api_get rejects non-/api path', (await call('api_get', { path: '/etc/passwd' })).isError);
|
||||||
|
|||||||
@@ -63,6 +63,15 @@ export interface Config {
|
|||||||
/** How often to re-crawl on a timer. Zero = only on demand. */
|
/** How often to re-crawl on a timer. Zero = only on demand. */
|
||||||
crawlIntervalMs: number;
|
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
|
* 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.
|
* 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 };
|
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. */
|
/** Like `int`, but 0 is meaningful (it disables the feature) rather than invalid. */
|
||||||
function intAllowingZero(name: string, fallback: number): number {
|
function intAllowingZero(name: string, fallback: number): number {
|
||||||
const raw = process.env[name]?.trim();
|
const raw = process.env[name]?.trim();
|
||||||
@@ -231,6 +260,7 @@ export function loadConfig(): Config {
|
|||||||
crawlIntervalMs: intAllowingZero('CRAWL_INTERVAL_MS', 6 * 60 * 60_000),
|
crawlIntervalMs: intAllowingZero('CRAWL_INTERVAL_MS', 6 * 60 * 60_000),
|
||||||
// Absolute for the same reason as the mirror: resolveWithin only returns
|
// Absolute for the same reason as the mirror: resolveWithin only returns
|
||||||
// an absolute path when the root it is given is one.
|
// 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,
|
notesDir: process.env.NOTES_DIR?.trim() ? resolve(process.env.NOTES_DIR.trim()) : undefined,
|
||||||
notesWritable: !bool('NOTES_READONLY', false),
|
notesWritable: !bool('NOTES_READONLY', false),
|
||||||
untisHistoryDays: intAllowingZero('UNTIS_HISTORY_DAYS', 180),
|
untisHistoryDays: intAllowingZero('UNTIS_HISTORY_DAYS', 180),
|
||||||
|
|||||||
117
src/core/day-note.ts
Normal file
117
src/core/day-note.ts
Normal file
@@ -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, ' ');
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CrawledBoard, Snapshot } from './crawl.ts';
|
import type { CrawledBoard, Snapshot } from './crawl.ts';
|
||||||
import { h5pSearchText } from './h5p.ts';
|
import { h5pSearchText } from './h5p.ts';
|
||||||
import { noteSearchText } from './notes.ts';
|
import { noteSearchText, noteSections } from './notes.ts';
|
||||||
import { matchesAll, snippet, tokenize } from './text.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
|
// follows a note back to a course, and the subject is what makes the hit
|
||||||
// readable — "Deutsch — my note" rather than a bare path.
|
// readable — "Deutsch — my note" rather than a bare path.
|
||||||
for (const note of snapshot.notes) {
|
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);
|
const haystack = noteSearchText(note);
|
||||||
if (!matchesAll(haystack, terms)) continue;
|
if (!matchesAll(haystack, terms)) continue;
|
||||||
hits.push({
|
hits.push({
|
||||||
|
|||||||
@@ -195,12 +195,20 @@ export function splitFrontmatter(raw: string): { front: NoteFrontmatter; body: s
|
|||||||
|
|
||||||
const front: NoteFrontmatter = {};
|
const front: NoteFrontmatter = {};
|
||||||
const extra: Record<string, string> = {};
|
const extra: Record<string, string> = {};
|
||||||
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());
|
const match = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line.trim());
|
||||||
if (!match) continue;
|
if (!match) continue;
|
||||||
const key = match[1]!.toLowerCase();
|
const key = match[1]!.toLowerCase();
|
||||||
const value = unquote(match[2]!.trim());
|
let value = unquote(match[2]!.trim());
|
||||||
if (!value) continue;
|
// `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) {
|
switch (key) {
|
||||||
case 'title':
|
case 'title':
|
||||||
front.title = value;
|
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 };
|
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<NoteDoc> {
|
||||||
|
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. */
|
/** A note as it is stored: frontmatter, then the body. */
|
||||||
export function renderNote(front: NoteFrontmatter, body: string): string {
|
export function renderNote(front: NoteFrontmatter, body: string): string {
|
||||||
const lines = [
|
const lines = [
|
||||||
@@ -362,6 +433,86 @@ async function freePath(root: string, relative: string): Promise<string> {
|
|||||||
throw new Error(`Too many notes named like ${relative}.`);
|
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 ------------------------------------------------------------
|
// --- matching ------------------------------------------------------------
|
||||||
|
|
||||||
/** Everything about a note that search should look at, as one string. */
|
/** 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');
|
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. */
|
/** Filters a list the way `list_notes` does. Pure, and shared with the CLI. */
|
||||||
export function filterNotes(
|
export function filterNotes(
|
||||||
notes: NoteDoc[],
|
notes: NoteDoc[],
|
||||||
@@ -376,7 +540,7 @@ export function filterNotes(
|
|||||||
): NoteDoc[] {
|
): NoteDoc[] {
|
||||||
const subject = filter.subject?.trim().toLowerCase();
|
const subject = filter.subject?.trim().toLowerCase();
|
||||||
return notes.filter((note) => {
|
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;
|
if (filter.courseId && note.courseId !== filter.courseId) return false;
|
||||||
// A note with no date cannot be excluded by a date window without
|
// A note with no date cannot be excluded by a date window without
|
||||||
// silently hiding it; undated notes always pass.
|
// 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;
|
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 {
|
function subjectFromPath(relative: string): string | undefined {
|
||||||
const parts = relative.split('/');
|
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. */
|
/** `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;
|
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[] {
|
function parseList(value: string): string[] {
|
||||||
const inner = /^\[(.*)\]$/.exec(value)?.[1] ?? value;
|
const inner = /^\[(.*)\]$/.exec(value)?.[1] ?? value;
|
||||||
return inner
|
return inner
|
||||||
|
|||||||
101
src/http/api.ts
101
src/http/api.ts
@@ -12,7 +12,18 @@ import {
|
|||||||
type FsErrorCode,
|
type FsErrorCode,
|
||||||
type WalkEntry,
|
type WalkEntry,
|
||||||
} from '../core/legacy-files.ts';
|
} 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 { resolveWithin } from '../core/paths.ts';
|
||||||
import { TokenRejected } from '../core/session-token.ts';
|
import { TokenRejected } from '../core/session-token.ts';
|
||||||
import type { Services } from '../services.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<typeof dayLessons> = [];
|
||||||
|
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) => {
|
router.get('/token', (_req: Request, res: Response) => {
|
||||||
res.json(tokenStatus(services));
|
res.json(tokenStatus(services));
|
||||||
});
|
});
|
||||||
|
|||||||
135
src/http/app-page.ts
Normal file
135
src/http/app-page.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
'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<string, { file: string; type: string }> = {
|
||||||
|
'/': { 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<string, Buffer>();
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
188
src/http/app/app.css
Normal file
188
src/http/app/app.css
Normal file
@@ -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; }
|
||||||
490
src/http/app/app.js
Normal file
490
src/http/app/app.js
Normal file
@@ -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);
|
||||||
|
})();
|
||||||
6
src/http/app/icon.svg
Normal file
6
src/http/app/icon.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Notizen">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#1f6feb"/>
|
||||||
|
<g fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round">
|
||||||
|
<path d="M18 20h28M18 32h28M18 44h18"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 277 B |
87
src/http/app/index.html
Normal file
87
src/http/app/index.html
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#1f6feb">
|
||||||
|
<title>Schulcloud — Notizen</title>
|
||||||
|
<link rel="icon" href="icon.svg" type="image/svg+xml">
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
<link rel="stylesheet" href="app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>Diese Seite braucht JavaScript.</noscript>
|
||||||
|
|
||||||
|
<!-- Login. Shown until /app/session says otherwise; everything else stays hidden. -->
|
||||||
|
<section id="login" class="screen" hidden>
|
||||||
|
<form id="login-form" class="card">
|
||||||
|
<h1>Anmelden</h1>
|
||||||
|
<label for="password">Passwort</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" required autofocus>
|
||||||
|
<button type="submit">Anmelden</button>
|
||||||
|
<p id="login-error" class="error" role="alert" aria-live="assertive"></p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="app" class="screen" hidden>
|
||||||
|
<header>
|
||||||
|
<nav class="tabs">
|
||||||
|
<button type="button" id="tab-notes" class="tab" aria-current="page">Notizen</button>
|
||||||
|
<button type="button" id="tab-settings" class="tab">Einstellungen</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Notes: one school day per note, one heading per lesson. -->
|
||||||
|
<main id="view-notes" class="view">
|
||||||
|
<div class="daybar">
|
||||||
|
<button type="button" id="prev" aria-label="Vorheriger Tag">‹</button>
|
||||||
|
<div class="daybar-centre">
|
||||||
|
<strong id="day-title">…</strong>
|
||||||
|
<input id="day-date" type="date" aria-label="Datum">
|
||||||
|
</div>
|
||||||
|
<button type="button" id="next" aria-label="Nächster Tag">›</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="day-status" class="status" role="status" aria-live="polite"></p>
|
||||||
|
<p id="day-conflict" class="conflict" role="alert" hidden></p>
|
||||||
|
|
||||||
|
<textarea id="editor" spellcheck="true" autocapitalize="sentences"
|
||||||
|
placeholder="Noch nichts für diesen Tag." aria-label="Notizen des Tages"></textarea>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="save">Speichern</button>
|
||||||
|
<button type="button" id="fill" hidden>Stunden ergänzen</button>
|
||||||
|
<span id="lessons-hint" class="hint"></span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Settings: the Schulcloud token, and what the server is doing. -->
|
||||||
|
<main id="view-settings" class="view" hidden>
|
||||||
|
<section class="card">
|
||||||
|
<h2>Schulcloud-Token</h2>
|
||||||
|
<p id="token-state" class="status">…</p>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>In einem privaten Fenster bei der Schulcloud anmelden.</li>
|
||||||
|
<li>DevTools → Application → Cookies → Wert des Cookies <code>jwt</code> kopieren.</li>
|
||||||
|
<li>Hier einsetzen und speichern. Der Server prüft ihn erst bei der Schulcloud.</li>
|
||||||
|
<li><strong>Das private Fenster schließen</strong> — offen gelassen meldet es den Token nach etwa zwei Stunden ab.</li>
|
||||||
|
</ol>
|
||||||
|
<form id="token-form">
|
||||||
|
<label for="jwt">Neuer jwt-Cookie</label>
|
||||||
|
<input id="jwt" type="password" autocomplete="off" spellcheck="false">
|
||||||
|
<button type="submit">Token ersetzen</button>
|
||||||
|
</form>
|
||||||
|
<p id="token-result" class="status" role="status" aria-live="polite"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Server</h2>
|
||||||
|
<dl id="server-state"></dl>
|
||||||
|
<button type="button" id="logout">Abmelden</button>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
src/http/app/manifest.webmanifest
Normal file
14
src/http/app/manifest.webmanifest
Normal file
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -12,11 +12,25 @@ import type { NextFunction, Request, Response } from 'express';
|
|||||||
*
|
*
|
||||||
* A route can accept more than one token: `/mcp` also takes the connector
|
* A route can accept more than one token: `/mcp` also takes the connector
|
||||||
* token claude.ai stores, which `/api` refuses.
|
* 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'));
|
const expected = (Array.isArray(accepted) ? accepted : [accepted]).map((token) => Buffer.from(token, 'utf8'));
|
||||||
|
|
||||||
return function authenticate(req: Request, res: Response, next: NextFunction): void {
|
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'));
|
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
|
// Every token is compared even after a match, so the timing does not
|
||||||
// tell which one was presented.
|
// tell which one was presented.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Config } from '../config.ts';
|
|||||||
import { createServer } from '../mcp/server.ts';
|
import { createServer } from '../mcp/server.ts';
|
||||||
import type { Services } from '../services.ts';
|
import type { Services } from '../services.ts';
|
||||||
import { createApiRouter } from './api.ts';
|
import { createApiRouter } from './api.ts';
|
||||||
|
import { createAppRouter } from './app-page.ts';
|
||||||
import { bearerAuth, pathSecret } from './auth.ts';
|
import { bearerAuth, pathSecret } from './auth.ts';
|
||||||
import { tokenPage, tokenScript } from './token-page.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,
|
// 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,
|
// 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.
|
// 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) {
|
if (config.authToken) {
|
||||||
app.use(MCP_PATH, bearerAuth(config.connectorToken ? [config.authToken, config.connectorToken] : 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 {
|
} else {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[schulcloud-mcp] MCP_AUTH_TOKEN is not set — the endpoint is UNAUTHENTICATED. ' +
|
'[schulcloud-mcp] MCP_AUTH_TOKEN is not set — the endpoint is UNAUTHENTICATED. ' +
|
||||||
|
|||||||
197
src/http/web-auth.ts
Normal file
197
src/http/web-auth.ts
Normal file
@@ -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<string, { count: number; first: number }>();
|
||||||
|
|
||||||
|
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.' });
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ import { germanDay, isCalendarDate, schoolToday } from '../../core/dates.ts';
|
|||||||
import {
|
import {
|
||||||
filterNotes,
|
filterNotes,
|
||||||
NoteNotFound,
|
NoteNotFound,
|
||||||
|
noteSections,
|
||||||
|
noteSubjects,
|
||||||
|
normalizeRelative,
|
||||||
readNoteAt,
|
readNoteAt,
|
||||||
readNotes,
|
readNotes,
|
||||||
writeNote,
|
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 ' +
|
'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 ' +
|
'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 ' +
|
'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. ' +
|
'date to get the lessons around a topic — a note is often a whole school day with a heading per ' +
|
||||||
'search finds notes by their contents as well.',
|
'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: {
|
inputSchema: {
|
||||||
subject: z
|
subject: z
|
||||||
.string()
|
.string()
|
||||||
@@ -78,7 +82,9 @@ export function registerNoteTools(server: McpServer, context: ServerContext): vo
|
|||||||
|
|
||||||
const terms = query ? tokenize(query) : [];
|
const terms = query ? tokenize(query) : [];
|
||||||
const matched = filterNotes(all, { subject, since, until }).filter(
|
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) {
|
if (matched.length === 0) {
|
||||||
return text(
|
return text(
|
||||||
@@ -115,16 +121,20 @@ export function registerNoteTools(server: McpServer, context: ServerContext): vo
|
|||||||
annotations: READ_ONLY,
|
annotations: READ_ONLY,
|
||||||
},
|
},
|
||||||
async ({ path }) => {
|
async ({ path }) => {
|
||||||
|
// A search hit inside a day's note carries `<path>#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 {
|
try {
|
||||||
return text(renderNote(await readNoteAt(root, path)));
|
return text(renderNote(await readNoteAt(root, wanted)));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof NoteNotFound) {
|
if (error instanceof NoteNotFound) {
|
||||||
return failure(
|
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.',
|
'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 {
|
function listLine(note: NoteDoc): string {
|
||||||
const when = note.date ? germanDay(note.date) : 'ohne Datum';
|
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 tags = note.tags.length > 0 ? ` · ${note.tags.map((tag) => `#${tag}`).join(' ')}` : '';
|
||||||
const first = firstLine(note.text);
|
// For a day's note the lessons are the useful preview; for a single piece of
|
||||||
return [`- **${note.title}** — ${when}${where}${tags} \`${note.path}\``, first ? ` ${first}` : undefined]
|
// 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)
|
.filter(Boolean)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNote(note: NoteDoc): string {
|
function renderNote(note: NoteDoc): string {
|
||||||
|
const subjects = noteSubjects(note);
|
||||||
const facts = [
|
const facts = [
|
||||||
note.date ? `Datum: ${germanDay(note.date)}` : undefined,
|
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.tags.length > 0 ? `Tags: ${note.tags.join(', ')}` : undefined,
|
||||||
note.courseId ? `Kurs: \`${note.courseId}\`` : undefined,
|
note.courseId ? `Kurs: \`${note.courseId}\`` : undefined,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|||||||
@@ -166,8 +166,13 @@ function nextStep(hit: SearchResult): string {
|
|||||||
? ` → \`fs_read\` with path \`${fsPath}\``
|
? ` → \`fs_read\` with path \`${fsPath}\``
|
||||||
: ` → \`fs_read\` with fileId \`${hit.nodeId}\` and name \`${hit.title}\``;
|
: ` → \`fs_read\` with fileId \`${hit.nodeId}\` and name \`${hit.title}\``;
|
||||||
}
|
}
|
||||||
// A note is addressed by its path, not by an id.
|
// A note is addressed by its path, not by an id — and a lesson inside a day's
|
||||||
if (hit.kind === 'note') return ` → \`get_note\` with path \`${hit.nodeId}\``;
|
// 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') {
|
if (hit.kind === 'untis') {
|
||||||
const periodId = hit.meta?.periodId;
|
const periodId = hit.meta?.periodId;
|
||||||
return ` → \`untis_lesson_topics\` with periodId \`${typeof periodId === 'number' ? periodId : hit.nodeId}\``;
|
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') {
|
if (hit.kind === 'note') {
|
||||||
const date = typeof hit.meta?.date === 'string' ? formatDate(hit.meta.date) : undefined;
|
const date = typeof hit.meta?.date === 'string' ? formatDate(hit.meta.date) : undefined;
|
||||||
const subject = typeof hit.meta?.subject === 'string' ? hit.meta.subject : 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
|
// Named as the user's own writing, so it is never quoted as if the school
|
||||||
// had published it.
|
// had published it. A lesson within a day's note says which lesson.
|
||||||
return ['my own note', subject, date].filter(Boolean).join(', ');
|
return ['my own note', subject ?? heading, date].filter(Boolean).join(', ');
|
||||||
}
|
}
|
||||||
if (hit.kind === 'untis') {
|
if (hit.kind === 'untis') {
|
||||||
const date = typeof hit.meta?.date === 'string' ? formatDate(hit.meta.date) : undefined;
|
const date = typeof hit.meta?.date === 'string' ? formatDate(hit.meta.date) : undefined;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import type { CrawledFile, Snapshot } from '../core/crawl.ts';
|
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 { mirrorPath } from '../core/paths.ts';
|
||||||
import { lessonLogText } from '../core/untis-history.ts';
|
import { lessonLogText } from '../core/untis-history.ts';
|
||||||
import { connect, migrate, type Db } from './db.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
|
// most notes sit outside any course — which is also what makes them survive
|
||||||
// a per-course crawl's carry-forward untouched.
|
// a per-course crawl's carry-forward untouched.
|
||||||
for (const note of snapshot.notes ?? []) {
|
for (const note of snapshot.notes ?? []) {
|
||||||
nodes.push({
|
const common = {
|
||||||
kind: 'note',
|
|
||||||
nodeId: note.path,
|
|
||||||
courseId: note.courseId ?? null,
|
courseId: note.courseId ?? null,
|
||||||
title: note.title,
|
|
||||||
body: noteSearchText(note),
|
|
||||||
path: `Notizen/${note.path}`,
|
|
||||||
meta: {
|
meta: {
|
||||||
|
notePath: note.path,
|
||||||
...(note.date ? { date: note.date } : {}),
|
...(note.date ? { date: note.date } : {}),
|
||||||
...(note.subject ? { subject: note.subject } : {}),
|
|
||||||
...(note.source ? { source: note.source } : {}),
|
...(note.source ? { source: note.source } : {}),
|
||||||
tags: note.tags,
|
tags: note.tags,
|
||||||
modifiedAt: note.modifiedAt,
|
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
|
// 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.
|
// 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]),
|
digest: digestOf([note.title, note.text, note.subject ?? '', note.date ?? '', note.tags]),
|
||||||
|
|||||||
115
test/day-note.test.ts
Normal file
115
test/day-note.test.ts
Normal file
@@ -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<UntisLesson> & { 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,8 +4,14 @@ import { tmpdir } from 'node:os';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import {
|
import {
|
||||||
|
dayNotePath,
|
||||||
filterNotes,
|
filterNotes,
|
||||||
|
NoteConflict,
|
||||||
NoteNotFound,
|
NoteNotFound,
|
||||||
|
noteSections,
|
||||||
|
noteSubjects,
|
||||||
|
replaceNote,
|
||||||
|
subjectFromHeading,
|
||||||
notePathFor,
|
notePathFor,
|
||||||
parseNote,
|
parseNote,
|
||||||
readNoteAt,
|
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', () => {
|
describe('filterNotes', () => {
|
||||||
const notes = [
|
const notes = [
|
||||||
parseNote('Deutsch/2026-09-15 A.md', 'a', STAMP),
|
parseNote('Deutsch/2026-09-15 A.md', 'a', STAMP),
|
||||||
|
|||||||
147
test/web-auth.test.ts
Normal file
147
test/web-auth.test.ts
Normal file
@@ -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<string, string>, 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user