Compare commits
32 Commits
feat/core-
...
feat/forma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c259d97f04 | ||
|
|
534b1b0f58 | ||
|
|
5c0b658855 | ||
|
|
ac61aea870 | ||
|
|
dc50b4bcd5 | ||
|
|
af4464decb | ||
|
|
ad8ba28313 | ||
|
|
a0cef532c6 | ||
|
|
19d9bce2f7 | ||
|
|
ccbf3ad3e9 | ||
|
|
eaf9c7aa38 | ||
|
|
74b97bc4dc | ||
|
|
87c5cedf3c | ||
|
|
196e10eacc | ||
|
|
ab581aa5ca | ||
|
|
bfccb3f343 | ||
|
|
bac91303bf | ||
|
|
ab265b5b0c | ||
|
|
973b82ebf5 | ||
|
|
9d0272c622 | ||
|
|
3e44e66dde | ||
|
|
bed3923902 | ||
|
|
10c6544579 | ||
|
|
5ae2210459 | ||
|
|
a3b17a680c | ||
|
|
237395e4f4 | ||
|
|
1de026ca43 | ||
|
|
3a168e37e5 | ||
|
|
521c21f7ae | ||
|
|
0ae9198428 | ||
|
|
a3aded110c | ||
|
|
290b352b07 |
@@ -11,3 +11,8 @@ scripts
|
||||
!scripts/copy-assets.mjs
|
||||
docs
|
||||
files.zip
|
||||
# Mirrored coursework and server logs: never needed to build, and the mirror
|
||||
# holds the account's files, which have no business in a build context.
|
||||
tmp
|
||||
# The local test instance has its own compose project.
|
||||
local-instance
|
||||
|
||||
97
.env.example
97
.env.example
@@ -10,6 +10,8 @@ TSC_URL=https://schulcloud-thueringen.de
|
||||
# TTL that the built-in keepalive holds open. IMPORTANT: close the Schulportal
|
||||
# window after copying this — an open tab shares the session and its auto-logout
|
||||
# will revoke this token ~2h after login. See docs/AUTH.md.
|
||||
# Needed for the first start. Later tokens go in with `schulcloud token set` or
|
||||
# the server's /token page, without a restart; a saved newer one wins over this.
|
||||
TSC_JWT_COOKIE=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -22,10 +24,33 @@ TSC_JWT_COOKIE=
|
||||
# openssl rand -hex 32
|
||||
MCP_AUTH_TOKEN=
|
||||
|
||||
# claude.ai: the token its connector sends as a request header
|
||||
# (`authorization: Bearer <token>`). Accepted on /mcp only, never on /api — which
|
||||
# can replace the Schulcloud token — because claude.ai stores it. At least 32
|
||||
# characters, different from MCP_AUTH_TOKEN; unset = off. Generate one with:
|
||||
# openssl rand -hex 32
|
||||
# MCP_CONNECTOR_TOKEN=
|
||||
|
||||
# Clients that cannot send a header: serve MCP at /<this value>/mcp with no
|
||||
# token at all. The URL becomes the credential — see "Connecting Claude" in
|
||||
# docs/DEPLOYMENT.md before using it. At least 32 URL-safe characters; unset =
|
||||
# off. Generate one with:
|
||||
# openssl rand -hex 32
|
||||
# MCP_PATH_SECRET=
|
||||
|
||||
# Where a Schulcloud token replaced at runtime (`schulcloud token set`, /token)
|
||||
# is saved, so a restart keeps it. docker-compose.yml sets /data/state; unset =
|
||||
# replacements last until the next restart.
|
||||
# STATE_DIR=/data/state
|
||||
|
||||
# Listen address inside the container. Leave as-is when running behind Caddy.
|
||||
PORT=8080
|
||||
BIND_HOST=0.0.0.0
|
||||
|
||||
# Local Docker only: the loopback port docker-compose.override.yml publishes the
|
||||
# container on. Default 8080; change it when that port is already taken.
|
||||
# MCP_HOST_PORT=8080
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Index and file mirror (optional — without these the server runs live-only:
|
||||
# search crawls on every call, and the CLI's /api surface is unavailable)
|
||||
@@ -42,11 +67,83 @@ DATABASE_URL=postgresql://schulcloud:schulcloud@postgres:5432/schulcloud
|
||||
# still downloadable, proxied live. Default 64 MiB.
|
||||
# MIRROR_MAX_BYTES=67108864
|
||||
|
||||
# Also index personal files ("Meine Dateien") and submitted / returned work,
|
||||
# including teacher grade comments. This is what makes "what did the teacher
|
||||
# say about X" searchable and lets what_changed report a re-grade. Costs roughly
|
||||
# three extra requests per task on a full crawl, so it is off by default.
|
||||
# INDEX_PERSONAL_FILES=false
|
||||
|
||||
# Also walk the file manager ("Dateien") when crawling: Kurs-Dateien for every
|
||||
# course, plus Persönliche, Team- and Geteilte Dateien on a full crawl. Many
|
||||
# teachers keep their material only there, so this is on by default. One page
|
||||
# load per folder — about 160 on a 26-course account.
|
||||
# INDEX_FILE_MANAGER=true
|
||||
|
||||
# How often to re-crawl on a timer, in ms. Default 21600000 (6h). 0 = on demand
|
||||
# only. A re-crawl of unchanged content downloads nothing, because Schulcloud
|
||||
# file records are immutable.
|
||||
# CRAWL_INTERVAL_MS=21600000
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Your own lesson notes (optional — what you wrote down in class)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Directory of Markdown files holding your own notes. With it set, the server
|
||||
# offers list_notes, get_note and add_note, indexes the notes so `search` finds
|
||||
# them, and the German prompts consult them. Unset = none of that exists.
|
||||
# docker-compose.yml sets /data/notes and gives it a volume; bind-mount a synced
|
||||
# folder there instead to write notes from a phone. See docs/NOTES.md.
|
||||
# NOTES_DIR=/data/notes
|
||||
|
||||
# Leave the files alone: list_notes and get_note still work, add_note and
|
||||
# POST /api/notes are refused. Right for a deployment whose notes are synced in
|
||||
# from somewhere else and should have exactly one writer.
|
||||
# NOTES_READONLY=false
|
||||
|
||||
# Password for the web app at /app — the notes editor and the settings page
|
||||
# where the Schulcloud token is replaced. Unset = the app is not served at all.
|
||||
#
|
||||
# Unlike every other credential here this one is typed by a person on a phone,
|
||||
# so it is a passphrase rather than a random token: at least 12 characters, and
|
||||
# three or four words is the right shape. It is hashed with scrypt at startup
|
||||
# and the plain value is never stored, compared or logged. Changing it logs out
|
||||
# every session, because the session signing key is derived from it.
|
||||
#
|
||||
# The app is on the internet like the rest of the endpoint. Failed logins are
|
||||
# rate-limited per address, but the password is the thing protecting the notes
|
||||
# and the Schulcloud token — pick a long one.
|
||||
# WEB_PASSWORD=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebUntis (optional — the timetable, which Schulcloud does not hold)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Where the school publishes its timetable. With these set, the server offers
|
||||
# untis_timetable, untis_homework and untis_lesson_topics, plus the German
|
||||
# "tagesvorbereitung" prompt; without them none of that exists. All four values
|
||||
# are in one dialog: WebUntis → Profil → Freigaben → Untis Mobile → QR-Code.
|
||||
#
|
||||
# UNTIS_SECRET is the "Schlüssel" field and is a credential: it authenticates
|
||||
# every request as this user, needs no password, works with an SSO login, and
|
||||
# stays valid until you generate a new key in that dialog. It can do whatever
|
||||
# the Untis Mobile app can — this server only ever calls read methods, by
|
||||
# allowlist (src/core/untis.ts). See docs/AUTH.md.
|
||||
#
|
||||
# Authentication is a time-based code, so the host's clock must be in sync;
|
||||
# WebUntis answers -8524 ("invalid client time") when it is not.
|
||||
# UNTIS_SERVER=yourschool.webuntis.com
|
||||
# UNTIS_SCHOOL=yourschool
|
||||
# UNTIS_USER=your.username
|
||||
# UNTIS_SECRET=
|
||||
|
||||
# How far back to read the class register ("Unterrichtsinhalt", plus the notes
|
||||
# teachers leave on a period) into the search index, in days. Default 180; 0
|
||||
# turns it off. Costs one timetable call per 90 days plus one per lesson series
|
||||
# on a full crawl — a few dozen requests for a school year. This is what makes
|
||||
# "what did we actually do before the test" searchable rather than something to
|
||||
# reconstruct one tool call at a time.
|
||||
# UNTIS_HISTORY_DAYS=180
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Limits (optional — sensible defaults are built in)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,3 +9,6 @@ vendor/
|
||||
# Local scratch
|
||||
tmp/
|
||||
files.zip
|
||||
|
||||
# Ids of the fixture the teacher simulation currently has in place.
|
||||
local-instance/.simulate-teacher.json
|
||||
|
||||
369
CLAUDE.md
369
CLAUDE.md
@@ -6,11 +6,19 @@ Guidance for Claude Code when working in this repository.
|
||||
|
||||
Read-only access to a Schulcloud (HPI Schul-Cloud / Schulcloud-Verbund-Software)
|
||||
account: courses, column boards, lessons, tasks, files with text extraction, and
|
||||
a Postgres-backed full-text index. TypeScript, Node 22+,
|
||||
`@modelcontextprotocol/sdk`.
|
||||
a Postgres-backed full-text index — plus the **timetable from WebUntis**, which
|
||||
is a separate system and the only place this school publishes when a lesson
|
||||
happens, or that it was cancelled, and **the user's own lesson notes**, a
|
||||
directory of Markdown files that is the only record of what was actually said in
|
||||
the room. TypeScript, Node 22+, `@modelcontextprotocol/sdk`.
|
||||
|
||||
Three sources, and the distinction matters in every tool description: Schulcloud
|
||||
has the material, WebUntis has the schedule and the class register, the notes
|
||||
have what the teacher stressed. An answer that silently merges them is worse
|
||||
than one that says which said what.
|
||||
|
||||
Three entry points over one core:
|
||||
- `src/bin/http.ts` — Streamable HTTP + `/api`, the deployed form, behind Caddy on a Pi.
|
||||
- `src/bin/http.ts` — Streamable HTTP + `/api` + `/app`, the deployed form, behind Caddy on a Pi.
|
||||
- `src/bin/stdio.ts` — stdio, for local Claude Code / Desktop use.
|
||||
- `src/bin/cli.ts` — the `schulcloud` CLI, which talks to the HTTP server, never
|
||||
to Schulcloud.
|
||||
@@ -26,18 +34,43 @@ npm run probe # verify token + API assumptions against the LIVE instance
|
||||
npm run smoke # full end-to-end: real server + real MCP client + real data
|
||||
npm run keepalive-status # is the deployed container holding its session?
|
||||
npm run session-diagnose # ~2.5h: measure what actually ends the session
|
||||
npm run publish-image # amd64 + arm64 image to registry.mc02.dev; clean tree only
|
||||
```
|
||||
|
||||
`docker-compose.override.yml` is local-only and publishes the server on
|
||||
`127.0.0.1:8080` and Postgres on `127.0.0.1:55432`; see `docs/LOCAL.md`.
|
||||
`127.0.0.1:8080` (or `MCP_HOST_PORT`) and Postgres on `127.0.0.1:55432`; see
|
||||
`docs/LOCAL.md`. Compose merges it whenever `COMPOSE_FILE` is unset, so the Pi's
|
||||
`.env` sets `COMPOSE_FILE=docker-compose.yml:deploy/docker-compose.pi.yml`
|
||||
instead; `docs/PI.md` is the setup guide. **The Pi never builds**: that file runs
|
||||
`registry.mc02.dev/schulcloud-mcp` and `!reset`s the build section, and
|
||||
`npm run publish-image` is how an image gets there — so a change reaches the
|
||||
Pi only once it is committed and published. When `.env` points at the live account, test against the local
|
||||
instance only through `local-instance/scripts/mcp-env.sh`: it pins its own
|
||||
database (`schulcloud_local`) and mirror, so fixtures cannot reach the live
|
||||
index.
|
||||
|
||||
`probe` and `smoke` hit the live Schulcloud and need a valid `.env`. Both are
|
||||
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,
|
||||
`src/mcp/` or `src/http/` — the unit tests cover only pure functions.
|
||||
|
||||
Run smoke **both ways**: with `DATABASE_URL` set (34 checks, index-backed) and
|
||||
without (32 checks, live-only). The degradation path is a supported mode, not a
|
||||
fallback nobody exercises.
|
||||
Run smoke **both ways**: with `DATABASE_URL` set (index-backed) and without
|
||||
(live-only). Without a WebUntis key the run asserts the `untis_*` tools are
|
||||
*not* offered instead of exercising them, and the same holds for `NOTES_DIR` —
|
||||
except that the smoke sets its own throwaway one, so the note tools are always
|
||||
exercised and can never touch real notes. The degradation paths are supported
|
||||
modes, not fallbacks nobody exercises.
|
||||
|
||||
The check counts are a tripwire, so re-measure them rather than trusting this
|
||||
line after a change: **106/107 against the local instance** on 2026-09-19 (the
|
||||
one failure is the H5P service, which that instance does not run). The live counts
|
||||
are stale — they were last taken before the notes and class-register work, and
|
||||
could not be retaken because the live session had lapsed. Every Schulcloud check fails with 401 when the live
|
||||
session has lapsed — check the container's keepalive log before suspecting code.
|
||||
|
||||
The editor's Markdown round trip is unit-tested; the **browser** side of
|
||||
`editor.js` is not, because nothing here runs one. It was checked by hand in
|
||||
Firefox against a page that drives the toolbar — WebKit, which is the engine on
|
||||
the phone this is written on, has still never run it.
|
||||
|
||||
Store tests need a database and skip without one:
|
||||
`TEST_DATABASE_URL=postgresql://… npm test`. They use a real Postgres on
|
||||
@@ -49,16 +82,17 @@ once put fixtures into real data.
|
||||
## Architecture
|
||||
|
||||
```
|
||||
bin/{http,stdio}.ts ─┬─ mcp/server.ts ── mcp/tools/*
|
||||
bin/{http,stdio}.ts ─┬─ mcp/server.ts ── mcp/tools/*, mcp/{resources,prompts}.ts
|
||||
└─ http/{server,api,auth}.ts /mcp and /api
|
||||
│
|
||||
bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync}.ts
|
||||
|
||||
services.ts (process-wide: client, Store, Indexer)
|
||||
services.ts (process-wide: client, Store, Indexer, UntisClient)
|
||||
│
|
||||
indexer/indexer.ts ── store/store.ts ── Postgres
|
||||
│
|
||||
core/{client,board,crawl,extract,text,paths,types}
|
||||
core/{untis,totp,dates} ── WebUntis, a second upstream
|
||||
```
|
||||
|
||||
- **`core/`** knows nothing of MCP, HTTP or the CLI.
|
||||
@@ -67,13 +101,106 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
|
||||
- **`crawl.ts`** — the one traversal. Search, the indexer, the what-changed
|
||||
diff and the file mirror all need it; keep it here, not in a tool.
|
||||
- `paths.ts` — the security boundary for mirrored filenames. See Invariants.
|
||||
- `legacy-files.ts` — the file manager ("Dateien": Persönliche, Kurs-, Team-,
|
||||
Geteilte Dateien) as one path tree, parsed from the legacy client's pages.
|
||||
A separate store from files-storage; the `fs_*` tools and `/api/fs` sit on it.
|
||||
- `session-token.ts` — the Schulcloud token, replaceable at runtime: checked
|
||||
with `GET /me` (same `userId`), swapped into `config.jwt`, saved to
|
||||
`STATE_DIR`. **Read `config.jwt` at the moment of use; never keep a copy.**
|
||||
- **`untis.ts`** — WebUntis, the second upstream: the timetable with its
|
||||
cancellations and substitutions, class-register homework and lesson topics.
|
||||
Authenticates each request with a one-time code (`totp.ts`) over the key
|
||||
from Profil → Freigaben, so there is no session and no keepalive on this
|
||||
side. Resolves the payload's element ids to names and returns every day in
|
||||
a range, empty ones included. See Invariants for why the allowlist is there.
|
||||
- **`day-note.ts`** — a school day as a note: the timetable turned into one
|
||||
`##` heading per lesson. `lessonHeading` and `notes.ts`'s
|
||||
`subjectFromHeading` are a **loop** — the app writes the heading and the
|
||||
indexer reads the subject back out of it, so a change to either without the
|
||||
other files a day's notes under nothing. `test/day-note.test.ts` holds them
|
||||
to it.
|
||||
- **`notes.ts`** — the user's own lesson notes as a directory of Markdown
|
||||
files with a small frontmatter dialect. A note with `##` headings is a
|
||||
school day and is indexed **per lesson**, not whole: indexed whole, every
|
||||
hit would read "my note, Monday" and "what did we do in Deutsch" would match
|
||||
a note whose other five lessons were something else. The files are the truth and the
|
||||
index is a view of them, so `list_notes`/`get_note` read disk and answer
|
||||
before the first crawl and while Postgres is down. **The one thing anything
|
||||
here writes** — see Invariants. `docs/NOTES.md` is the guide.
|
||||
- **`untis-history.ts`** — the class register read backwards, which is what
|
||||
puts "what did we actually cover" into the search index. Its whole reason
|
||||
for existing is one API property: `getLessonTopic2017` answers per *series*,
|
||||
so a term costs one call per lesson series rather than one per period — see
|
||||
API gotchas.
|
||||
- `h5p.ts` — the quizzes on a board. One GET per element, parsed into
|
||||
questions and answers; board assembly attaches it like a pad, the crawl
|
||||
indexes its text, and `get_h5p` prints it.
|
||||
- `dates.ts` — school days as `YYYY-MM-DD` in Europe/Berlin. The container
|
||||
runs UTC, so `schoolToday()` is not `new Date()`: at 00:30 in Erfurt the
|
||||
process clock still says yesterday, and a nightly briefing would prepare
|
||||
the wrong day.
|
||||
- **`http/`** — `/mcp` and `/api` take `MCP_AUTH_TOKEN`; `/mcp` alone also takes
|
||||
`MCP_CONNECTOR_TOKEN`, the request header claude.ai stores, which must never
|
||||
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
|
||||
`/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. `app.js` is an ES **module**; a new asset must be added
|
||||
to `ASSETS` *and* to the route's regex in `app-page.ts`, or it 404s.
|
||||
- **`http/app/markdown.js` + `editor.js`** — the note is edited as formatted
|
||||
text and stored as Markdown, and these two are that translation.
|
||||
`markdown.js` is the pair `markdownToHtml` / `markdownFromDom`; `editor.js`
|
||||
drives a `contenteditable` element with `execCommand` (no library: the CSP
|
||||
allows no outside script and the app has no bundler). **The round trip must
|
||||
settle**: one pass may tidy a note, a second must change nothing, and
|
||||
`editor.js` checks exactly that before opening a note formatted — a note that
|
||||
fails opens in the Markdown view instead. `test/app-markdown.test.ts` covers
|
||||
it against `test/mini-dom.ts`, ~60 lines of read-only DOM, because losing a
|
||||
lesson's notes to a lossy serializer is not a bug anyone can recover from.
|
||||
Pasted HTML goes through Markdown before it reaches the document, which is
|
||||
where sanitising and formatting are the same operation.
|
||||
- **`http/web-auth.ts`** — the app's login, which is a different kind of
|
||||
credential from everything else here: a password a person types, not a token a
|
||||
program was configured with. scrypt at startup, a signed `HttpOnly` /
|
||||
`SameSite=Strict` session cookie, per-address rate limiting. The session key
|
||||
is **derived from the password**, so changing it logs every session out and
|
||||
there is no second secret to store. The cookie opens `/api` — a session *is*
|
||||
the user — and never `/mcp`. See Invariants.
|
||||
- **`store/`** — crawl generations, identity diffs, `german` + `pg_trgm` FTS.
|
||||
`Store.open` returns `undefined` when Postgres is down; callers degrade.
|
||||
- **`indexer/`** — crawl → persist → mirror bytes → extract text → index.
|
||||
Coalesces concurrent refreshes; enforces a minimum interval.
|
||||
Coalesces concurrent refreshes; enforces a minimum interval. The crawl walks
|
||||
topic-attached tasks too, which the course page does not list: without that
|
||||
they are unsearchable and their grades invisible. `INDEX_PERSONAL_FILES`
|
||||
additionally indexes personal files and submitted/returned work, including
|
||||
grade comments — that is what makes "what got graded this week" answerable,
|
||||
at roughly three extra requests per task.
|
||||
- **`mcp/tools/*.ts`** — tool descriptions are prompts: they are how Claude picks
|
||||
a tool, so they carry the German domain terms (Kurse, Themen, Aufgaben) and say
|
||||
when *not* to use the tool.
|
||||
- **`mcp/tools/untis.ts`** — the `untis_*` tools, registered **only** when
|
||||
`UNTIS_*` is configured: a tool that can only fail is worse than a missing
|
||||
one. `readTimetable` is shared with the prompt, the way `readCourse` is.
|
||||
`untis_lesson_topics` takes **either** a `periodId` (one series) **or** a
|
||||
`subject` (a whole term, via `untis-history.ts`); neither and both are both
|
||||
refused, because guessing which was meant is worse than asking.
|
||||
- **`mcp/tools/notes.ts`** — `list_notes`, `get_note` and `add_note`, registered
|
||||
**only** when `NOTES_DIR` is set, by the same rule as the `untis_*` tools.
|
||||
- **`mcp/prompts.ts` takes a `Sources` flag** (`{ notes, untis }`) so a prompt
|
||||
never tells Claude to call a tool this deployment does not register. A prompt
|
||||
built with no sources names none of them — that is the default, and the safe
|
||||
one.
|
||||
- **`mcp/resources.ts`, `mcp/prompts.ts`** — courses and rooms as resources a
|
||||
person attaches, carrying exactly `readCourse`/`readRoom`, the functions behind
|
||||
`get_course`/`get_room`; and three prompts, the third being
|
||||
`tagesvorbereitung`, which attaches a day's timetable and is the point where
|
||||
the two systems meet. What people read in a picker (labels, prompt texts) is
|
||||
German; what the model reads stays English.
|
||||
- **`context.ts`** — per-session state. Only `/me` is cached, because the school
|
||||
id is on every files-storage path and cannot change for a token.
|
||||
|
||||
@@ -83,8 +210,44 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
|
||||
`GET` except `extendSession` (the keepalive's `refresh-session` call, which
|
||||
touches only our own session and is not exposed as a tool, so no model-driven
|
||||
call can be a POST). `api_get` rejects non-`/api/` paths and anything carrying a
|
||||
scheme or host. `refresh_index` and `POST /api/refresh` write only to the Pi's
|
||||
own index and mirror — every upstream call they make is still a GET.
|
||||
scheme or host. **Against the legacy client, GET-only is not enough:**
|
||||
`GET /files/share/` mints a share token and `GET /files/file?share=` grants a
|
||||
permission, so `getFileManagerPage` allows only the listing routes, by pattern —
|
||||
widen that pattern only with a route you have read the handler of. A pre-signed
|
||||
download URL is fetched with **no** credentials: it names another host, and
|
||||
neither the bearer nor the `jwt` cookie may go with it. `refresh_index` and `POST /api/refresh` write only to the Pi's
|
||||
own index and mirror, and `PUT /api/token` only to the server's own token — every
|
||||
upstream call they make is still a GET.
|
||||
|
||||
**The app's session opens `/api`, never `/mcp`, and the app is not served
|
||||
without a password.** `WEB_PASSWORD` is the only credential here a human types,
|
||||
so it is the only one that can be guessed: the rate limiter in `web-auth.ts` is
|
||||
not decoration, and the scrypt cost that makes guessing expensive is itself a
|
||||
denial-of-service vector without it. The password is hashed at startup and never
|
||||
stored, compared or logged in the clear — it is a secret by the rule below, and
|
||||
so is the session cookie. Unset means the app does not exist, the same rule the
|
||||
`untis_*` and note tools follow: a login screen no password can open is worse
|
||||
than no page, because it looks like a way in.
|
||||
|
||||
**The notes directory is the only thing anything here writes to.** That is not
|
||||
an exception to the invariant above — it is a different store: the user's own
|
||||
files, never Schulcloud and never WebUntis. It is bounded by the same two
|
||||
functions as the file mirror, and for the same reason: `add_note`'s title and
|
||||
subject arrive from a tool call, become path components through `safeComponent`,
|
||||
and the result is checked by `resolveWithin`, so a note titled
|
||||
`../../.ssh/authorized_keys` becomes a filename. `NOTES_READONLY` refuses writes
|
||||
entirely. Do not widen this to anything outside `NOTES_DIR`, and do not take it
|
||||
as precedent for a Schulcloud write tool — that decision is the one above, and
|
||||
it has not changed.
|
||||
|
||||
**WebUntis is read-only by allowlist, not by verb.** Its API is JSON-RPC, so
|
||||
every call is a POST, reads included — "GET only" cannot carry over. Instead
|
||||
`core/untis.ts` holds `READ_METHODS` and `assertReadMethod` refuses anything
|
||||
else at the single choke point, which `test/untis.test.ts` asserts. This matters
|
||||
because the key is the mobile app's credential and can do what the app can: the
|
||||
live account's rights include `W_OWN_ABSENCE`, i.e. that key could report the
|
||||
user absent. Add a method only after reading what it does upstream, and never
|
||||
one whose name starts with `submit`, `create`, `save` or `delete`.
|
||||
|
||||
**Filenames from Schulcloud are untrusted paths.** Course titles, card titles
|
||||
and filenames are all user-supplied upstream, and both the server's mirror and
|
||||
@@ -98,8 +261,14 @@ the property that makes that acceptable. Do not add a write tool without the
|
||||
user explicitly asking for one and understanding this.
|
||||
|
||||
**Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the
|
||||
account; `MCP_AUTH_TOKEN` guards the endpoint. Neither belongs in
|
||||
account; `MCP_AUTH_TOKEN`, `MCP_CONNECTOR_TOKEN` and `WEB_PASSWORD` guard the
|
||||
endpoint;
|
||||
`UNTIS_SECRET` authenticates as the user in WebUntis and outlives every other
|
||||
credential here, since it does not expire. None belongs in
|
||||
logs, error messages, or tool output. `.env` is git-ignored — keep it that way.
|
||||
Two more count as secrets: a token replaced at runtime (it lives only in
|
||||
`STATE_DIR`, mode 0600) and, when `MCP_PATH_SECRET` is set, **request paths** —
|
||||
so nothing may log a URL path, and config errors describe the rule, not the value.
|
||||
|
||||
**Live behaviour beats upstream source.** The clones in `vendor/` track `main`
|
||||
and may be ahead of what is deployed. When they disagree with the instance, the
|
||||
@@ -111,7 +280,14 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
|
||||
|
||||
- Course contents are at `GET /api/v3/course-rooms/{courseId}/board`. There is
|
||||
no `GET /api/v3/courses/{id}`, and `:roomId` there is the *course* id.
|
||||
- `/api/v3/rooms` is an unrelated newer feature, not courses. Empty is normal.
|
||||
- **Rooms ("Räume") are a separate space from courses, and the UI's naming is a
|
||||
trap**: the sidebar's *Kurse* entry links to `/rooms/courses-overview` and
|
||||
lists courses; *Räume* links to `/rooms` and lists rooms. A `/rooms/...` url
|
||||
says nothing about which. `list_rooms`/`get_room` cover the latter; a room
|
||||
holds boards only — no lessons, no tasks. Empty is normal and is also what a
|
||||
revoked membership looks like.
|
||||
- Room boards report `isVisible`, which the course-page projection does not, so
|
||||
a room's drafts can be named as drafts instead of being tried and 403ing.
|
||||
- `limit` is rejected above 100 though the spec says 99. Page at 99; the client
|
||||
clamps and `listAllCourses` pages for you.
|
||||
- There is no `GET /tasks/{id}`, and the task lists omit `description` — it
|
||||
@@ -123,10 +299,53 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
|
||||
- **Never swallow a per-item crawl error.** Board failures used to be caught and
|
||||
dropped, so the index lost whole boards while the crawl reported success —
|
||||
which is how the 20-id limit went unnoticed. They go into `Snapshot.failures`.
|
||||
- **Record failures by kind, or transient ones become permanent.** Every failed
|
||||
file used to be marked done, so a timeout was never retried. A *download*
|
||||
failure is now recorded with `retry: true` and the next crawl tries again; an
|
||||
*extraction* failure is final. Two causes found on the first full crawl of a
|
||||
real account: downloads bounded by the 30 s request timeout (11 MB scans cut
|
||||
off mid-transfer — downloads now time out on 30 s of *silence*), and PDF text
|
||||
containing NUL, which Postgres `text` refuses — stripped in `recordFileText`.
|
||||
- **A full crawl can outlast one HTTP request.** The first one with the file
|
||||
manager took 14 minutes (every file downloaded once); Node's fetch abandons a
|
||||
response without headers after 5. `POST /api/refresh` takes `wait: false` and
|
||||
the CLI polls `/api/status`; `refresh_index` returns after 50 s and leaves the
|
||||
crawl running. Don't reintroduce a caller that waits on a full crawl inline.
|
||||
- **`GET /lessons/{id}/tasks` is a bare array whose items carry no id.** Not the
|
||||
`{data,total}` envelope, and `LessonLinkedTaskResponse` has no id field at
|
||||
all. A topic-attached task is thus unidentifiable from the API and invisible
|
||||
in both task lists once past due — 18 of 60 tasks on the real account.
|
||||
`core/lesson-page.ts` scrapes the ids off the legacy topic page.
|
||||
- **Collaborative text editor (Etherpad) contents are reachable, in two hops.**
|
||||
`GET /api/v3/collaborative-text-editor/content-element/{id}` returns the pad
|
||||
url *and* sets an Etherpad `sessionID` cookie; `/etherpad/p/{id}/export/txt`
|
||||
then returns the text. No Etherpad API key needed. `core/etherpad.ts` checks
|
||||
the url's host before sending the cookie to it.
|
||||
- **A draft board is listed on the course page but 403s when opened.** Say "not
|
||||
published yet", not "no access".
|
||||
- **File records are mutable**: `PATCH /file/rename/{id}` keeps the id and size,
|
||||
so the store's digest has to include the name.
|
||||
- **Submissions: only `GET /submissions/status/task/{taskId}` exists.** No list,
|
||||
no fetch-by-id, and the payload has no submitted text, grade comment or
|
||||
graded-at — `/api/v1`, which had them, is not served here. Don't imply absent
|
||||
feedback means none was given.
|
||||
graded-at. Don't imply absent feedback means none was given.
|
||||
- **`/api/v1` is partly served, and it is production surface.** Exactly three
|
||||
legacy routes survive in the deployment's own ingress table
|
||||
(`dof_app_deploy/ansible/group_vars/all/x_ingress.yml`): **`/api/v1/courses`,
|
||||
`/api/v1/users`, `/api/v1/classes`**. Everything else under `/api/v1` is
|
||||
unrouted and 404s. They matter because v3 dropped things they still carry:
|
||||
`courses` has the description, `teacherIds`, `userIds` and `times` (the weekly
|
||||
timetable), and `users/{id}` is the **only** way to turn a user id into a name
|
||||
— submission `submitters`, file `creatorId` and course `teacherIds` are
|
||||
otherwise unreadable. Permission is per-account: a teacher may read their
|
||||
students, a student may read only themselves, so name resolution must degrade
|
||||
to "not visible to this account" rather than printing a bare id.
|
||||
- **The teacher's homework page is a different page from the student's.** Its
|
||||
tabs are `extended` and `submissions`, not `submission` and `feedback`, and
|
||||
the grade lives in the grading *form* (`name="grade"`, `name="gradeComment"`,
|
||||
one block per `submissionId`) rather than in rendered prose. The student
|
||||
parser finds nothing on it, which is why a teacher account reported every
|
||||
graded submission as "neither a percentage nor feedback was found" while the
|
||||
data was plainly there. `parseTeacherGrading` handles that side.
|
||||
- **A grade is a percentage (`Number` 0-100) or absent; there is no text grade.**
|
||||
Teachers commonly grade with `gradeComment` alone, so "graded by feedback" is
|
||||
a complete answer. `formatGradeState` in `mcp/tools/submissions.ts` owns that
|
||||
@@ -153,11 +372,56 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
|
||||
dedicated endpoints (`/boards/{id}`, `/cards`, file records) are stable.
|
||||
- Many course PDFs are **image-only scans with no text layer** (3 of 4 sampled),
|
||||
so extraction legitimately yields nothing. `extract.ts` detects this and says
|
||||
so; do not "fix" it by retrying.
|
||||
so; do not "fix" it by retrying. `download_file` then falls back to
|
||||
`GET /file/preview/...`, which renders the page as a picture Claude can read —
|
||||
the answer for a scan, though it still leaves the file unsearchable.
|
||||
- **The preview endpoint has two enums, and both 400 without saying so.**
|
||||
`width` accepts only **50, 150 or 500** — a number outside that set is a
|
||||
validation error naming the value but not the permitted set. `outputFormat`
|
||||
accepts only **`image/webp`**; omitting it is worse than wrong, because the
|
||||
preview is then rendered in the *source* format and a PDF comes back as a
|
||||
PDF. The response also labels itself `webp` rather than `image/webp`, so the
|
||||
content type has to be normalised before anything will treat it as an image.
|
||||
- **A room's `allowedOperations` is an object, not a list.** Every operation is
|
||||
present with a boolean; `false` means denied. Typing it as `string[]`
|
||||
type-checks and throws `.some is not a function` the moment anything reads it.
|
||||
- **Schulcloud has no quiz of its own — a quiz is H5P.** There is no quiz module
|
||||
or endpoint upstream: interactive exercises are `h5p` elements carrying a
|
||||
`contentId`, or external (LTI) tools behind `contextExternalToolId`. Don't
|
||||
look for a quiz API; look for the H5P one.
|
||||
- **One request holds a whole quiz**: `GET /api/v3/h5p-editor/params/{contentId}`
|
||||
returns the JSON the player is fed — every question, every option and which
|
||||
are correct — even though the player shows one question at a time. Nothing to
|
||||
step through, no page to scrape, and `play/{id}` is the same content plus 24 KB
|
||||
of scripts, so `params` is both cheaper and complete. The H5P service is not
|
||||
in `docs-json` and has no document of its own; `core/h5p.ts` interprets the
|
||||
payload, whose shape belongs to the H5P library the teacher used. Model a new
|
||||
library there rather than in a tool, and keep the generic harvest as the
|
||||
fallback — an exercise arriving as "0 questions" is the bug that path exists
|
||||
to prevent.
|
||||
- **The file manager is a third store, reachable only as HTML.** Persönliche,
|
||||
Kurs-, Team- and Geteilte Dateien live in the legacy `files` collection, not in
|
||||
files-storage: `list_files` answers 0 for a course holding dozens of
|
||||
worksheets, and 21 of 26 live courses keep material there. Its Feathers service
|
||||
is not in the ingress, so listings are parsed from `/files/{my,courses,teams,shared}`
|
||||
pages and downloads go through `GET /files/signedurl` (JSON). Folders are
|
||||
addressed by id alone — `/files/courses/{course}/{folder}` at any depth.
|
||||
`permittedDirectories` returns every course with **no** folders (it queries
|
||||
`refOwnerModel: 'courses'`, records say `'course'`), and `/files/search/` 504s,
|
||||
so walk listings. Course names contain `/`; resolve by joining segments. A
|
||||
listing page that does not parse must throw, never read as an empty folder —
|
||||
"0 files" is the bug this exists to fix. `docs/API.md` has the evidence.
|
||||
- **Teams cannot be read at any version.** v3 exposes only
|
||||
`GET /team/{teamId}/news`; upstream `main`'s teams controller is write-only
|
||||
(`POST :teamId/create-room`). `/teams` is the legacy client's HTML page, not
|
||||
an API. A team's *files* are reachable, through the file manager (`/teams/<team>`).
|
||||
- **`exp` (30 days) is not the session lifetime.** The binding limit is a Valkey
|
||||
whitelist entry with a `JWT_TIMEOUT_SECONDS` TTL (7200s; live value at
|
||||
`GET /api/v3/config/public`) that every authenticated request re-sets.
|
||||
`src/keepalive.ts` holds it open — don't remove it.
|
||||
`src/keepalive.ts` holds it open — don't remove it. It cannot hold it across
|
||||
downtime: a host off for more than two hours loses the session (seen when a
|
||||
dev machine was off overnight), which is why the deployment is an always-on
|
||||
Pi and why a fresh token can be swapped in without a restart.
|
||||
- **A Schulportal tab left open revokes our token.** The `jwt` cookie *is* the
|
||||
browser's session token, same `jti`. The front end runs a client-side timer
|
||||
(reset only on route change, never from the server TTL) and calls
|
||||
@@ -166,6 +430,36 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
|
||||
the tab. This produced two false conclusions before being found — if a token
|
||||
dies ~2h after login, suspect an open tab first. `docs/AUTH.md` has the chain.
|
||||
|
||||
WebUntis has its own set; `docs/API.md` has them all, these are the ones that
|
||||
cost the most:
|
||||
|
||||
- **`jsonrpc_intern.do` needs `?v=i3.2`.** Without it the call fails with
|
||||
`-8998` wrapping a Java NullPointerException, which reads like a malformed
|
||||
body and is not. Errors also arrive with **HTTP 200** and an `error` member,
|
||||
so check the body before the status.
|
||||
- **The one-time code travels as a string.** One code in ten begins with a
|
||||
zero, and a JSON number drops it — a login that fails 10% of the time, which
|
||||
is the worst kind of failure to debug.
|
||||
- **`startDateTime` ends in `Z` and is local time.** `2026-09-21T08:00Z` is the
|
||||
08:00 lesson in Erfurt. Never hand these to `new Date`; `splitLocal` takes
|
||||
the string apart. Every date the tools send is computed in Europe/Berlin
|
||||
(`core/dates.ts`), because the container is UTC.
|
||||
- **A substitution is two periods**: the original with `is: ["CANCELLED"]` and
|
||||
the replacement beside it with `is: ["IRREGULAR"]`. A lesson is not "changed
|
||||
in place", so both have to be read, and `orgId` on an element exists as well.
|
||||
- **Announced tests live in `text.info`**, not in the exam module — this school
|
||||
does not use it, so `getExams2017` is always empty. That field is the most
|
||||
valuable thing in the payload.
|
||||
- **`getLessonTopic2017` answers per *series*, not per period.** Its
|
||||
`previousTopics` are the lessons *before* the period you name, each carrying
|
||||
its own `periodId`, so a term is reconstructed by taking the distinct
|
||||
`lessonId`s in a range, asking about the **latest** period of each, and
|
||||
merging back by id — a few dozen calls for a school year. Asking about the
|
||||
earliest period of a series reaches none of its history. `core/untis-history.ts`.
|
||||
- **A day with no lessons is not a holiday.** Vocational school weeks spent at
|
||||
the company simply have no periods, and `holidays` says nothing about them.
|
||||
Do not report "Ferien" for them; say there are no lessons.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Imports use `.ts` extensions; `rewriteRelativeImportExtensions` makes `tsc`
|
||||
@@ -183,16 +477,45 @@ These cost real time to discover; `docs/API.md` has the full list with evidence.
|
||||
|
||||
## Adding a tool
|
||||
|
||||
1. Add the client method in `core/client.ts` (`GET` only).
|
||||
1. Add the client method in `core/client.ts` (`GET` only) — or, for the
|
||||
timetable side, in `core/untis.ts`, whose method name must go in
|
||||
`READ_METHODS` and must read.
|
||||
2. Register the tool in the relevant `mcp/tools/*.ts`, with a description that
|
||||
says when to use it *and when not to*.
|
||||
3. Format output as Markdown, keeping ids visible for follow-up calls.
|
||||
4. If it reads the index, handle `context.store === undefined` with a message
|
||||
saying what is unavailable and what still works.
|
||||
saying what is unavailable and what still works. A `untis_*` tool instead
|
||||
registers only when `context.untis` exists, and a note tool only when
|
||||
`config.notesDir` is set.
|
||||
5. Add a check to `scripts/smoke.mjs` and run `npm run smoke` both ways.
|
||||
|
||||
## Resources and prompts
|
||||
|
||||
Claude Code is the client these are tested in, and it shapes them. Read from its
|
||||
bundle (2.1.272), not its docs:
|
||||
|
||||
- **A prompt command's arguments are split on whitespace, and extra words are
|
||||
dropped** (`zipObject(argNames, input.split(/\s+/))`). A value of several words
|
||||
can only arrive joined, so `argumentText` turns `_` back into spaces and reads
|
||||
`-` as "skipped"; course names match by fragments (`resolveTarget`). Any new
|
||||
free-text argument needs the same treatment.
|
||||
- **The @ autocomplete fuzzy-matches `name` but displays `description`**, falling
|
||||
back to the name only when there is none — a description must carry the name.
|
||||
- **An @-mention resolves only URIs from `resources/list`**; a template alone
|
||||
cannot be mentioned. `McpServer` returns every template's listing in one reply
|
||||
and ignores cursors, which suits a few dozen entries and not the file manager.
|
||||
- **Throw `ProtocolError`, not `McpError`**: McpError prefixes its message with
|
||||
"MCP error <code>:", the client prefixes it again, and people read both.
|
||||
- A resource listing that fails yields no entries rather than an error: one
|
||||
refused kind would otherwise cost the whole reply.
|
||||
|
||||
## Environment
|
||||
|
||||
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`. See `.env.example`
|
||||
for the full set and `docs/AUTH.md` for refreshing the JWT. `npm run probe`
|
||||
reports both clocks: days until hard expiry and seconds of idle budget left.
|
||||
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`, and optionally
|
||||
`MCP_CONNECTOR_TOKEN` or `MCP_PATH_SECRET`, plus the four `UNTIS_*` values (all
|
||||
four or none — a half-filled block is a paste that went wrong, so it throws),
|
||||
`NOTES_DIR`, `UNTIS_HISTORY_DAYS` and `WEB_PASSWORD` (the app at `/app`);
|
||||
docker-compose sets `STATE_DIR` and `NOTES_DIR`. See `.env.example` for the
|
||||
full set and `docs/AUTH.md` for refreshing the JWT — `schulcloud token set`,
|
||||
no restart. `npm run probe` and `schulcloud token` report the clocks: days until
|
||||
hard expiry and the session budget.
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -30,12 +30,16 @@ COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY package.json ./
|
||||
|
||||
# The mirror is the one writable path. Creating it in the image with the right
|
||||
# owner matters: Docker initialises a new named volume from the image directory,
|
||||
# The mirror, the state directory (a replaced session token) and the notes are
|
||||
# the only writable paths. Creating them in the image with the right owner
|
||||
# matters: Docker initialises a new named volume from the image directory,
|
||||
# including its ownership, so without this the volume lands root-owned and the
|
||||
# unprivileged user gets EACCES on every write — with the failure recorded per
|
||||
# file rather than crashing, which makes it easy to miss.
|
||||
RUN mkdir -p /data/mirror && chown -R node:node /data
|
||||
# unprivileged user gets EACCES on every write — with the failure recorded
|
||||
# rather than crashing, which makes it easy to miss. The state directory holds a
|
||||
# credential and the notes are personal, so only their owner may enter either.
|
||||
RUN mkdir -p /data/mirror /data/state /data/notes \
|
||||
&& chown -R node:node /data \
|
||||
&& chmod 700 /data/state /data/notes
|
||||
|
||||
# node:alpine ships an unprivileged `node` user.
|
||||
USER node
|
||||
|
||||
143
README.md
143
README.md
@@ -1,8 +1,10 @@
|
||||
# schulcloud-mcp
|
||||
|
||||
Read-only access to a [Schulcloud](https://github.com/hpi-schul-cloud) account —
|
||||
courses, boards, lessons, tasks and files — for **Claude**, via MCP, and for
|
||||
**you**, via a CLI that mirrors your coursework to disk.
|
||||
courses, boards, lessons, tasks and files — plus the timetable from
|
||||
[WebUntis](https://www.untis.at/) and **the notes you take in class**, for
|
||||
**Claude**, via MCP, and for **you**, via a CLI that mirrors your coursework to
|
||||
disk.
|
||||
|
||||
Both are front ends over one core library and one live Schulcloud session, kept
|
||||
alive on a Pi.
|
||||
@@ -16,8 +18,12 @@ instance, not inferred from the upstream source.
|
||||
> *"What do I have due this week?"*
|
||||
> *"Find the material about Verschlüsselung and explain the Caesar cipher worksheet."*
|
||||
> *"Summarise the routing lesson from the LF10 course."*
|
||||
> *"What do I have tomorrow, and has anything been cancelled?"*
|
||||
> *"Quiz me on the DIN 5008 exercise from the DK room."*
|
||||
> *"What did we actually cover in Deutsch before the test — and what did I write down?"*
|
||||
|
||||
Thirteen tools, all read-only:
|
||||
Thirty-one tools. Everything that touches Schulcloud and WebUntis is read-only;
|
||||
`add_note` writes a file in your own notes directory and nowhere else.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -30,20 +36,80 @@ Thirteen tools, all read-only:
|
||||
| `list_tasks` | homework across all courses, by due date |
|
||||
| `get_task` | one task: description, due date, attachments, **and your submission** |
|
||||
| `list_submissions` | what you handed in, and what is still ungraded |
|
||||
| `list_files` | files attached to any entity |
|
||||
| `download_file` | fetch a file and extract its text, or view an image |
|
||||
| `list_files` | files attached to a board element, topic, task or submission |
|
||||
| `download_file` | fetch such an attachment and extract its text, or view an image |
|
||||
| `fs_list` | list a folder of the file manager — Persönliche, Kurs-, Team-, Geteilte Dateien |
|
||||
| `fs_tree` | everything below a file-manager folder, as a tree |
|
||||
| `fs_find` | find file-manager files and folders by name |
|
||||
| `fs_read` | read a file-manager file, extracted like `download_file` |
|
||||
| `search` | keyword search across everything — **including the text inside PDFs and Office files** |
|
||||
| `refresh_index` | re-read Schulcloud now, per course or in full |
|
||||
| `what_changed` | what appeared, changed or vanished since a date |
|
||||
| `index_status` | how fresh the index is |
|
||||
| `list_rooms` | rooms ("Räume"), which are a separate space from courses |
|
||||
| `get_room` | one room: its boards, members and what you may do there |
|
||||
| `list_classes` | classes ("Klassen") with their teachers, and group membership |
|
||||
| `list_news` | school and course announcements |
|
||||
| `get_h5p` | an H5P exercise in full: every question, option and correct answer |
|
||||
| `api_get` | GET-only escape hatch for uncovered API surface |
|
||||
| `untis_timetable` | the school day from **WebUntis**: lessons, Entfall, Vertretung, room changes, period notes |
|
||||
| `untis_homework` | homework from WebUntis' class register — a separate list from Schulcloud's tasks |
|
||||
| `list_notes` | your own lesson notes — what you wrote down, by subject or date |
|
||||
| `get_note` | one of your notes in full |
|
||||
| `add_note` | write a note down during or after a lesson |
|
||||
| `untis_lesson_topics` | what previous lessons of a subject actually covered ("Unterrichtsinhalt") |
|
||||
|
||||
`download_file` extracts text from **PDF, DOCX, XLSX, PPTX and OpenDocument**
|
||||
files and returns **images inline** for Claude to look at. Image-only PDFs —
|
||||
scans with no text layer, which are common in this account — are reported as
|
||||
such rather than as an empty result.
|
||||
|
||||
**Attach a course instead of asking for it.** Every course and room is also an
|
||||
MCP resource (`schulcloud://courses/<id>`, `schulcloud://rooms/<id>`) holding
|
||||
the same overview `get_course` and `get_room` return. In Claude Code, type `@`
|
||||
and part of the course name.
|
||||
|
||||
**Your own notes are the third source.** Schulcloud has the material and
|
||||
WebUntis has the schedule; neither has what the teacher actually stressed. Point
|
||||
`NOTES_DIR` at a directory of Markdown files and `search`, `what_changed` and
|
||||
all three prompts read it alongside everything else — including a migration path
|
||||
out of Apple Notes. See [docs/NOTES.md](docs/NOTES.md).
|
||||
|
||||
## The notes app
|
||||
|
||||
A small web app at `/app`, for writing those notes: a login, the day's notes,
|
||||
and a settings page for the Schulcloud token. Set `WEB_PASSWORD` to serve it.
|
||||
|
||||
One note per school day, one `##` heading per lesson — and **the headings come
|
||||
from WebUntis**, so opening a day gives you it already laid out with times,
|
||||
teachers, rooms, cancellations dropped and substitutions marked. Each heading is
|
||||
indexed as its own lesson, so a search answers "my own note, Deutsch,
|
||||
18.09.2026" rather than "Friday".
|
||||
|
||||
Writing is **formatted, not Markdown**: headings, bold, lists, tick boxes,
|
||||
quotes, links and tables come from a toolbar, and `MD` shows the Markdown
|
||||
underneath when you want it. The file on disk stays Markdown either way — that
|
||||
is what the index reads and what outlives the app.
|
||||
|
||||
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:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `zusammenfassung` *kurs* [*fokus*] | summarise a course or room: topics, tasks, key material, each with its source |
|
||||
| `pruefungsvorbereitung` *kurs* [*thema*] [*datum*] | prepare for an exam: scope, explanations, practice questions, a study plan |
|
||||
| `tagesvorbereitung` [*tag*] | prepare a school day: the lessons from WebUntis, what is new in those courses, what is due |
|
||||
|
||||
In Claude Code they run as `/mcp__schulcloud__zusammenfassung Mathe_10b`, or
|
||||
`/mcp__schulcloud__tagesvorbereitung morgen` the evening before.
|
||||
Claude Code splits arguments on spaces and drops extra words, so join words
|
||||
with `_` (`Lineare_Funktionen`) and skip an optional argument with `-`. *kurs*
|
||||
is any unambiguous part of a course or room name, or its id; *tag* takes
|
||||
`heute`, `morgen`, `übermorgen`, `21.09.2026` or `2026-09-21`.
|
||||
|
||||
## The CLI
|
||||
|
||||
```bash
|
||||
@@ -51,10 +117,17 @@ schulcloud login --server https://mcp.example.org --token <token>
|
||||
schulcloud sync --dry-run # see what would be mirrored
|
||||
schulcloud sync # mirror coursework to ~/Schulcloud
|
||||
schulcloud refresh --course <id>
|
||||
schulcloud fs tree /courses # browse the file manager ("Dateien")
|
||||
schulcloud fs get "/courses/<course>/<folder>"
|
||||
schulcloud note ls --subject Deutsch
|
||||
pbpaste | schulcloud note add --title Subnetting --subject LF07
|
||||
schulcloud token set # the monthly chore: hand the Pi a fresh Schulcloud token
|
||||
```
|
||||
|
||||
It talks only to the Pi and holds no Schulcloud credential — see
|
||||
[docs/CLI.md](docs/CLI.md).
|
||||
[docs/CLI.md](docs/CLI.md). A fresh token can also be pasted into the server's
|
||||
`/token` page; either way the server checks it with Schulcloud and swaps it in
|
||||
without a restart.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -66,8 +139,11 @@ npm run probe # verifies the token and API against the live instan
|
||||
```
|
||||
|
||||
To try it on your own machine — Docker stack, Claude Code, and the CLI — follow
|
||||
[docs/LOCAL.md](docs/LOCAL.md). To put it on a Pi behind Caddy, see
|
||||
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
||||
[docs/LOCAL.md](docs/LOCAL.md). To put it on a Pi behind Caddy and a VPS, and
|
||||
connect claude.ai, follow [docs/PI.md](docs/PI.md);
|
||||
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) explains the pieces. To add the notes
|
||||
app to a server that is already running, follow
|
||||
[docs/DEPLOY-NOTES.md](docs/DEPLOY-NOTES.md).
|
||||
|
||||
Getting `TSC_JWT_COOKIE` takes four clicks in DevTools and then lasts 30 days —
|
||||
provided you close the Schulportal window afterwards. See
|
||||
@@ -86,6 +162,34 @@ copy is the browser's own session token**, so a Schulportal tab left open will
|
||||
auto-logout after ~2 hours and revoke this server's token with it. Copy the
|
||||
token in a private window and close it. See [docs/AUTH.md](docs/AUTH.md).
|
||||
|
||||
**The timetable comes from somewhere else.** Schulcloud holds the material for
|
||||
a lesson but not the lesson: this school's course `times` are empty and it
|
||||
publishes its schedule in **WebUntis**. So the server reads that too, through
|
||||
the API the Untis Mobile app uses — which authenticates with a key from Profil →
|
||||
Freigaben and a time-based code per request, meaning no password, no session to
|
||||
hold open and nothing that expires monthly. The two systems answer different
|
||||
halves of the same question, and the German `tagesvorbereitung` prompt is where
|
||||
they meet: which lessons happen today, what changed in their courses, what is
|
||||
due. That key *can* write (the app may report an absence), so this side is kept
|
||||
read-only by an allowlist of five read methods rather than by "GET only". Unset
|
||||
`UNTIS_*` and none of it exists — the tools are not even offered. See
|
||||
[docs/AUTH.md](docs/AUTH.md).
|
||||
|
||||
**A quiz is one request, not a wizard.** Schulcloud has no quiz of its own, so
|
||||
an exercise is an H5P element and the board hands over nothing but a content
|
||||
id. The player then shows one question at a time, which makes a quiz look like
|
||||
something to step through or scrape — it isn't: the endpoint the player loads
|
||||
returns the whole exercise, every option and every solution. So `get_h5p`
|
||||
prints all of it, `get_board` names it with its question count, and `search`
|
||||
reaches the question text like any other material.
|
||||
|
||||
**claude.ai gets a token of its own.** Its connector stores a request header,
|
||||
so `MCP_CONNECTOR_TOKEN` opens `/mcp` and nothing else — it is refused on
|
||||
`/api`, which can replace the Schulcloud token — and rotates without touching
|
||||
Claude Code or the CLI, which keep `MCP_AUTH_TOKEN`. A client that cannot send
|
||||
headers can use a secret path instead (`MCP_PATH_SECRET`). See
|
||||
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
||||
|
||||
**Read-only by construction.** Every method on the API client is a `GET`,
|
||||
including `api_get`. The endpoint is internet-facing by necessity (Claude's
|
||||
connectors call it from Anthropic's cloud), so the fact that a leaked token
|
||||
@@ -118,22 +222,24 @@ bypass, "what's new since…" — are sketched with their trade-offs in
|
||||
|
||||
```
|
||||
src/
|
||||
core/ client, types, board assembly, crawler, extraction, paths
|
||||
core/ client, types, board assembly, crawler, extraction, paths,
|
||||
WebUntis and its class register, your own notes
|
||||
store/ Postgres: crawl generations, diffs, full-text search
|
||||
indexer/ crawl → persist → mirror bytes → extract text → index
|
||||
mcp/ MCP server and tools
|
||||
http/ express app, bearer auth, /api for the CLI
|
||||
mcp/ MCP server, tools, resources and prompts
|
||||
http/ express app, bearer auth, /api for the CLI, /app for people
|
||||
cli/ CLI config, API client, sync engine
|
||||
bin/ http, stdio and cli entry points
|
||||
docs/ API findings, auth, deployment, CLI, roadmap
|
||||
deploy/ Caddyfile snippet
|
||||
docs/ API findings, auth, deployment, CLI, notes, roadmap
|
||||
deploy/ Caddyfile snippet, the Pi's compose file
|
||||
scripts/ probe, smoke, session diagnostics
|
||||
vendor/ upstream clones, git-ignored, for reference only
|
||||
```
|
||||
|
||||
`core/` knows nothing about MCP, HTTP or the CLI: it holds the Schulcloud client,
|
||||
the traversal every feature needs, document extraction, and the path
|
||||
sanitisation that both the server's mirror and the CLI's sync depend on.
|
||||
the traversal every feature needs, document extraction, the WebUntis client with
|
||||
its one-time codes, and the path sanitisation that both the server's mirror and
|
||||
the CLI's sync depend on.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -144,13 +250,14 @@ npm run probe # check assumptions against the live instance
|
||||
npm run smoke # full end-to-end: real server, real client, real data
|
||||
npm run session-diagnose # instrument what actually ends the session (~2.5h)
|
||||
npm run keepalive-status # is the deployed container holding its session?
|
||||
npm run publish-image # build amd64 + arm64 and push to registry.mc02.dev — the Pi only pulls
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
`npm run smoke` starts the HTTP server, connects a real MCP client over
|
||||
Streamable HTTP and exercises every tool against the live account — 30 checks
|
||||
covering the auth gate, the protocol handshake, every content chain, file
|
||||
extraction, `api_get`'s guard rails and error handling.
|
||||
Streamable HTTP and exercises every tool against the live account — 91 checks (93 with the index, 9 fewer without a WebUntis key)
|
||||
covering the auth gate, the connector token and the secret path, the protocol handshake, every content chain, file
|
||||
extraction, resources and prompts, token replacement, `api_get`'s guard rails and error handling.
|
||||
|
||||
## Upstream
|
||||
|
||||
|
||||
@@ -14,7 +14,12 @@ mcp.example.org {
|
||||
|
||||
# `schulcloud-mcp` is the Compose service name; Docker's embedded DNS
|
||||
# resolves it on the shared network. No host port is published. One proxy
|
||||
# serves both surfaces: /mcp for Claude and /api for the CLI.
|
||||
# serves all three surfaces: /mcp for Claude, /api for the CLI, and /app for
|
||||
# the notes app in a browser.
|
||||
#
|
||||
# Caddy sets X-Forwarded-Proto on its own, and the app needs it: the hop to
|
||||
# the container is plain HTTP, so that header is the only evidence the
|
||||
# session cookie may be marked Secure. Do not strip it.
|
||||
reverse_proxy schulcloud-mcp:8080 {
|
||||
# MCP's Streamable HTTP transport keeps a server-sent-events channel
|
||||
# open for server-initiated messages. Without flush_interval -1 Caddy
|
||||
@@ -38,9 +43,13 @@ mcp.example.org {
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/schulcloud-mcp.log
|
||||
format json
|
||||
# Request URLs are not secrets here (the token is in a header, not the
|
||||
# path), but the Authorization header must never be written to disk.
|
||||
# Caddy does not log headers by default; do not add them.
|
||||
# With MCP_PATH_SECRET set, a request path *is* a credential: claude.ai
|
||||
# reaches the server at /<secret>/mcp. The filter rewrites that segment
|
||||
# before the entry is written. The Authorization header must never reach
|
||||
# disk either; Caddy does not log headers by default — do not add them.
|
||||
format filter {
|
||||
request>uri regexp ^/[A-Za-z0-9_-]{32,}/mcp /<secret>/mcp
|
||||
wrap json
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
29
deploy/docker-compose.pi.yml
Normal file
29
deploy/docker-compose.pi.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
# The Pi's additions to docker-compose.yml — see docs/PI.md.
|
||||
#
|
||||
# Selected by one line in the Pi's .env, so plain `docker compose` commands use it:
|
||||
#
|
||||
# COMPOSE_FILE=docker-compose.yml:deploy/docker-compose.pi.yml
|
||||
#
|
||||
# Setting COMPOSE_FILE also keeps docker-compose.override.yml out. That file is
|
||||
# for local development only, and Compose would otherwise merge it silently: it
|
||||
# publishes ports and turns the crawl timer off.
|
||||
|
||||
services:
|
||||
schulcloud-mcp:
|
||||
# The Pi runs the image published to the registry and never builds one:
|
||||
# scripts/publish-image.sh builds it for arm64 and amd64 elsewhere. `!reset`
|
||||
# drops the build section docker-compose.yml declares, so no `docker compose`
|
||||
# command here can fall back to building (Compose 2.24 or later).
|
||||
image: registry.mc02.dev/schulcloud-mcp:${SCHULCLOUD_MCP_TAG:-latest}
|
||||
build: !reset null
|
||||
environment:
|
||||
# The bundled Postgres, by name on the private backend network. Overrides
|
||||
# any DATABASE_URL in .env.
|
||||
DATABASE_URL: postgresql://schulcloud:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/schulcloud
|
||||
|
||||
networks:
|
||||
caddy:
|
||||
# Join the network the Pi's Caddy container is already on, so Caddy reaches
|
||||
# this server by name and no host port is published.
|
||||
external: true
|
||||
name: ${CADDY_NETWORK:?set CADDY_NETWORK in .env to the Docker network of your Caddy container}
|
||||
@@ -1,6 +1,7 @@
|
||||
# Local development only. Compose merges this automatically; it is not used on
|
||||
# the Pi, where Caddy reaches the container over the shared Docker network and
|
||||
# no host port is published.
|
||||
# Local development only. Compose merges this automatically whenever
|
||||
# COMPOSE_FILE is unset — which is why the Pi's .env sets COMPOSE_FILE to
|
||||
# deploy/docker-compose.pi.yml instead (docs/PI.md): there, Caddy reaches the
|
||||
# container over the shared Docker network and no host port is published.
|
||||
services:
|
||||
postgres:
|
||||
ports:
|
||||
@@ -12,9 +13,12 @@ services:
|
||||
environment:
|
||||
# The bundled postgres service, reachable by name on the compose network.
|
||||
DATABASE_URL: postgresql://schulcloud:${POSTGRES_PASSWORD:-schulcloud}@postgres:5432/schulcloud
|
||||
# Crawl on demand while developing rather than every 6 hours.
|
||||
CRAWL_INTERVAL_MS: 0
|
||||
# On demand by default while developing. Set CRAWL_INTERVAL_MS in .env to
|
||||
# crawl on a timer instead — what_changed can only report what happened
|
||||
# between crawls, so against a real account on-demand leaves it empty.
|
||||
CRAWL_INTERVAL_MS: ${CRAWL_INTERVAL_MS:-0}
|
||||
ports:
|
||||
# Bound to loopback: this exposes the account's data, and the bearer token
|
||||
# is the only thing in front of it.
|
||||
- "127.0.0.1:8080:8080"
|
||||
# MCP_HOST_PORT moves it when 8080 is already taken on this machine.
|
||||
- "127.0.0.1:${MCP_HOST_PORT:-8080}:8080"
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
# Standalone Compose file for the Pi.
|
||||
# The server and its Postgres.
|
||||
#
|
||||
# If you already run Caddy and PostgreSQL from another Compose project, either
|
||||
# merge the `schulcloud-mcp` service below into that project's file, or keep
|
||||
# this file separate and attach it to the existing Caddy network — see the
|
||||
# `networks` block at the bottom and deploy/Caddyfile.snippet.
|
||||
# Locally, docker-compose.override.yml is merged in automatically (docs/LOCAL.md).
|
||||
# On the Pi, .env selects deploy/docker-compose.pi.yml instead, which attaches
|
||||
# the server to the network of the Caddy already running there (docs/PI.md).
|
||||
|
||||
services:
|
||||
# Dev/standalone Postgres. On the Pi, point DATABASE_URL at the existing
|
||||
# instance instead and remove this service — the schema lives in its own
|
||||
# database and user, so it coexists with whatever else is already there.
|
||||
# The index's own Postgres, on a private network with the server. An existing
|
||||
# instance works too — point DATABASE_URL at it and drop this service — but
|
||||
# nothing requires sharing one.
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
container_name: schulcloud-mcp-db
|
||||
@@ -19,8 +18,10 @@ services:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-schulcloud}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
# Only the server talks to Postgres. Keeping it off the Caddy network keeps
|
||||
# it out of reach of whatever else shares that network on the Pi.
|
||||
networks:
|
||||
- caddy
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U schulcloud -d schulcloud"]
|
||||
interval: 10s
|
||||
@@ -37,13 +38,22 @@ services:
|
||||
PORT: 8080
|
||||
BIND_HOST: 0.0.0.0
|
||||
MIRROR_DIR: /data/mirror
|
||||
STATE_DIR: /data/state
|
||||
NOTES_DIR: /data/notes
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
# The mirror is the one thing this server writes; everything else stays
|
||||
# read-only, so it gets its own volume rather than loosening read_only.
|
||||
# The mirror, a replaced Schulcloud token and the user's own notes are the
|
||||
# only things this server writes; everything else stays read-only, so each
|
||||
# gets its own volume rather than loosening read_only.
|
||||
- mirror:/data/mirror
|
||||
- state:/data/state
|
||||
# The notes. Swap this line for a bind mount to keep them in a folder the
|
||||
# user already syncs (Syncthing, Nextcloud, an Obsidian vault) and write
|
||||
# them from a phone instead of through the API — see docs/NOTES.md:
|
||||
# - /home/pi/Notizen:/data/notes
|
||||
- notes:/data/notes
|
||||
# No ports are published to the host: Caddy reaches the container over the
|
||||
# shared Docker network, so the only way in from the internet is through
|
||||
# Caddy's TLS and this server's bearer check.
|
||||
@@ -51,6 +61,7 @@ services:
|
||||
- "8080"
|
||||
networks:
|
||||
- caddy
|
||||
- backend
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -67,10 +78,13 @@ services:
|
||||
volumes:
|
||||
pgdata:
|
||||
mirror:
|
||||
state:
|
||||
notes:
|
||||
|
||||
networks:
|
||||
backend:
|
||||
caddy:
|
||||
# Set to true once this joins the network your existing Caddy already uses,
|
||||
# and change the name to match (`docker network ls` to find it).
|
||||
# On the Pi, deploy/docker-compose.pi.yml makes this the network your
|
||||
# existing Caddy already uses (see docs/PI.md).
|
||||
external: false
|
||||
name: caddy
|
||||
|
||||
240
docs/API.md
240
docs/API.md
@@ -89,10 +89,18 @@ does not exist. The route that returns a course's lessons/tasks/boards is
|
||||
`GET /api/v3/course-rooms/{roomId}/board`, and its `:roomId` is the *course* id.
|
||||
Nothing in the naming suggests this.
|
||||
|
||||
**`/api/v3/rooms` is a different feature.** "Rooms" are the newer standalone
|
||||
collaboration spaces, unrelated to courses. On this instance the account has
|
||||
none, so `GET /api/v3/rooms` returns `{"data":[]}` — which reads like a broken
|
||||
endpoint but is simply an empty feature.
|
||||
**`/api/v3/rooms` is a different feature, and the UI's naming hides it.** Rooms
|
||||
("Räume") are the newer standalone collaboration spaces. The sidebar's *Kurse*
|
||||
entry links to `/rooms/courses-overview` and lists **courses** (served by
|
||||
`/api/v3/dashboard` + `/api/v3/courses`), while *Räume* links to `/rooms` and
|
||||
lists **rooms** — so a url containing `/rooms` identifies neither.
|
||||
|
||||
A room holds boards and nothing else: no lessons, no tasks. `GET /rooms`
|
||||
answers `{"data":[]}` with no `total`, derived from real memberships, so an
|
||||
empty result means the account is in no rooms — which is also what it looks
|
||||
like after a teacher deletes a room or revokes access. `GET /rooms/{id}/boards`
|
||||
does report `isVisible`, unlike the course-page projection, so a room's draft
|
||||
boards can be identified without trying to open one.
|
||||
|
||||
**`limit` maxima are enforced and mis-documented.** The OpenAPI schema says
|
||||
`maximum: 99`; the runtime validator rejects anything `> 100`. Page at 99 to
|
||||
@@ -173,6 +181,31 @@ clearest case in this API of live behaviour diverging from upstream source.
|
||||
instance configuration, including the session timeouts and feature flags. Handy
|
||||
for checking deployed settings without a token.
|
||||
|
||||
**`GET /lessons/{id}/tasks` returns a bare array, and its items have no id.**
|
||||
Every other list endpoint returns `{data, total}`; this one returns the array
|
||||
directly, so reading `.data` silently yields `undefined`. Worse, the items are
|
||||
`LessonLinkedTaskResponse`, which has no id property at all — name, description
|
||||
and dates only. A task attached to a topic is therefore unidentifiable from the
|
||||
API: it is not a task element on the course page (the topic reports only
|
||||
`numberOfPublishedTasks`), and once past due it is in neither `/tasks` nor
|
||||
`/tasks/finished`. On the account this server was built for that hid 18 of 60
|
||||
tasks, submissions and grades included. The ids are recoverable only from the
|
||||
legacy topic page, which links each task as `/homework/{id}` —
|
||||
`core/lesson-page.ts`.
|
||||
|
||||
**A student's task lists exclude past-due tasks.** `/tasks` drops a task once
|
||||
its due date passes; `/tasks/finished` holds only what the student ticked off.
|
||||
A submitted, graded, past-due task is in neither. Reach it through the course
|
||||
page, or through its topic.
|
||||
|
||||
**An unpublished board is listed but cannot be opened.** The course-board
|
||||
projection reports a draft board with its title, while `GET /boards/{id}`
|
||||
answers 403 for anyone who cannot edit it. Treat a 403 there as "probably not
|
||||
published yet", not as an access problem.
|
||||
|
||||
**`PATCH /file/rename/{fileRecordId}` mutates a file record in place.** The id
|
||||
and size stay the same, so any change detection keyed on those alone misses it.
|
||||
|
||||
**`Content-Disposition` on downloads is malformed.** It comes back as
|
||||
`attachment;; filename="…"` — note the doubled semicolon — and the filename is
|
||||
percent-encoded inside the quotes. Parse defensively.
|
||||
@@ -183,9 +216,202 @@ From `ContentElementType` in `schulcloud-server`, all seen live except where
|
||||
noted: `richText`, `file`, `fileFolder`, `link`, `drawing`,
|
||||
`collaborativeTextEditor`, `externalTool`, `videoConference`, `h5p`, `deleted`.
|
||||
|
||||
Collaborative text editor contents are **not** retrievable through the API —
|
||||
`GET /api/v3/collaborative-text-editor/{parentType}/{parentId}` returns a URL to
|
||||
the Etherpad-style editor, not the document text.
|
||||
Collaborative text editor elements come back with `content: {}` — no pad id, no
|
||||
url, nothing. `GET /api/v3/collaborative-text-editor/content-element/{elementId}`
|
||||
returns the pad url, and **also sets an Etherpad `sessionID` cookie** in its
|
||||
response. With that cookie, Etherpad's own `/etherpad/p/{padId}/export/txt`
|
||||
returns the document as plain text. So the contents *are* reachable, in two
|
||||
hops and without Etherpad's API key; `core/etherpad.ts` does this. The url is
|
||||
built from the server's `ETHERPAD__PAD_URI`, so it must be checked against the
|
||||
instance host before the session cookie is sent to it.
|
||||
|
||||
### H5P elements are the quizzes, and one request holds a whole one
|
||||
|
||||
An `h5p` element carries nothing but `content: { contentId }`. The content
|
||||
itself comes from the H5P service, whose API lives under `/api/v3/h5p-editor/`
|
||||
— not in `docs-json`, and with no document of its own
|
||||
(`/api/v3/h5p-editor/docs-json` is a 404). The deployment's ingress table routes
|
||||
`/h5p/player` and `/h5p/editor`, which are the front-end apps, not this API.
|
||||
|
||||
- **`GET /api/v3/h5p-editor/params/{contentId}`** returns the JSON the player is
|
||||
fed: `{ h5p: <metadata>, library, params: { metadata, params } }`. The inner
|
||||
`params` is the exercise — **every question, every option and which are
|
||||
correct** — so the player showing one question at a time is a display detail,
|
||||
not a limit on what can be read. Bearer auth, same as everything else.
|
||||
- `GET /api/v3/h5p-editor/play/{contentId}` is the same content wrapped in the
|
||||
player's integration object: 74 KB against 51 KB for the live quiz below,
|
||||
because it carries script and style lists. `params` is both smaller and
|
||||
complete, so nothing needs `play`.
|
||||
- `GET /api/v3/elements/{elementId}` returns a single board element with its
|
||||
content, which is how an element's `contentId` can be re-read without its
|
||||
board. (`/api/v3/board/element/{id}` does not exist.)
|
||||
|
||||
The **shape inside `params` belongs to the H5P library** the teacher used, which
|
||||
is where the work is. Verified against the live quiz "Quiz zur formalen
|
||||
Gestaltung einer Projektdoku" (`H5P.QuestionSet`, 20 questions):
|
||||
|
||||
- A `QuestionSet` holds `questions: [{ library, params, subContentId }]`, each
|
||||
sub-content naming its own library — `H5P.MultiChoice 1.16` here. Any other
|
||||
main library *is* a single question, with the same `params` shape.
|
||||
- `H5P.MultiChoice`: `question` and `answers[].text` are HTML,
|
||||
`answers[].correct` is the solution, `answers[].tipsAndFeedback.tip` a hint,
|
||||
and `behaviour.singleAnswer` is what makes the player draw radio buttons —
|
||||
the only honest source for "tick exactly one".
|
||||
- `H5P.TrueFalse` stores `correct` as the **string** `"true"`/`"false"`, with
|
||||
the button labels in `l10n`.
|
||||
- Cloze libraries (`H5P.Blanks`, `H5P.DragText`, `H5P.MarkTheWords`) mark the
|
||||
solutions inside the text as `*answer:tip*`, alternatives separated by `/`.
|
||||
- `H5P.SingleChoiceSet` and `H5P.Summary` put the **correct option first** and
|
||||
let the player shuffle; nothing else marks it.
|
||||
- Every payload also carries `UI`, `l10n`, `behaviour` and `overallFeedback`
|
||||
subtrees of button labels and display settings. Anything that harvests text
|
||||
generically has to skip them, or the exercise reads as "Überprüfen,
|
||||
Wiederholen, Absenden".
|
||||
|
||||
`core/h5p.ts` models the libraries above and harvests the text of anything else
|
||||
under a label saying so — a teacher's exercise reported as "0 questions" would
|
||||
be worse than a clumsy rendering of it.
|
||||
|
||||
## The file manager ("Dateien") is a third store
|
||||
|
||||
Persönliche Dateien, Kurs-Dateien, Team-Dateien and Geteilte Dateien are the
|
||||
**legacy file system**: a `files` collection with real folders (`isDirectory`,
|
||||
`parent`, `owner`, `refOwnerModel`), served by the legacy Feathers
|
||||
`fileStorage` service. It shares nothing with files-storage. Asking
|
||||
`/api/v3/file/list/school/{school}/courses/{courseId}` answers **0** for a
|
||||
course whose file manager holds dozens of worksheets — measured: 21 of 26
|
||||
courses on the live account keep files here, some nothing else.
|
||||
|
||||
**Its service is not in the public ingress**, so the only way in is the legacy
|
||||
client. Verified live:
|
||||
|
||||
| Route (legacy client, `jwt` cookie) | Returns | Notes |
|
||||
|---|---|---|
|
||||
| `GET /files/my/` , `/files/my/{folder}` | HTML listing | personal root / one folder |
|
||||
| `GET /files/courses/` | HTML listing | the courses, *as folders* (ids = course ids) |
|
||||
| `GET /files/courses/{course}` , `/files/courses/{course}/{folder}` | HTML listing | **one** folder segment at any depth |
|
||||
| `GET /files/teams/…` | HTML listing | same shape as courses |
|
||||
| `GET /files/shared/` | HTML listing | flat; shared *folders* cannot be opened (no route; the UI's link 404s) |
|
||||
| `GET /files/signedurl?file={id}&name={name}` | `{"url": …}` | pre-signed S3 url, **another host** (live: `s3.hidrive.strato.com`) |
|
||||
| `GET /files/permittedDirectories/` | JSON tree | **lists every course with no folders in any** — see below |
|
||||
| `GET /files/search/?q=` | HTML | **504** on live: an unindexed regex over every file record |
|
||||
|
||||
Listings are parsed on the attributes the page's own scripts use:
|
||||
`data-folder-id` with the name inside `.card-title-directory` (emitted
|
||||
**unescaped**, `{{{stripOnlyScript name}}}`), and `data-file-id`,
|
||||
`data-file-name`, `data-file-size`, `data-file-viewer-type` on each
|
||||
`.card.file`. A blocked file carries `btn-file-danger` and no viewer type. No
|
||||
dates are rendered.
|
||||
|
||||
**`permittedDirectories` is broken for courses.** The directory service's
|
||||
query matches course folders on `refOwnerModel: 'courses'`; the records say
|
||||
`'course'`. Live result: 26 courses, 0 folders; personal folders come through.
|
||||
Listings are the only complete view — which is also what the UI shows.
|
||||
|
||||
**Some GET routes write.** `GET /files/share/?file=` mints a share token when
|
||||
the file has none (`PATCH /fileStorage/shared/{id}`), and
|
||||
`GET /files/file?…&share=…` grants the caller a permission on the file.
|
||||
`GET /files/fileModel/{id}/proxy` forwards to the latter. A GET-only client is
|
||||
therefore *not* read-only against this surface by itself: `core/client.ts`
|
||||
allows only the listing routes and `/files/signedurl`, by pattern.
|
||||
|
||||
**The signed-url service returns its error instead of throwing it**
|
||||
(`.catch((err) => new Forbidden(err))`), so a refused file is a 200 whose body
|
||||
has no `url`.
|
||||
|
||||
**Course names contain `/`** in real data ("LF07 - FIA24A/B - Sb/Ha",
|
||||
"FIA24/FIP24 IT LF12"), and a real file is literally called `..docx`. Paths
|
||||
built from names cannot be split naively; `core/legacy-files.ts` resolves by
|
||||
trying joined segments, and accepts ids as segments.
|
||||
|
||||
Two more, seen while building the local fixture:
|
||||
|
||||
- **`getRefOwnerModel(owner)` answers "a course, or else `teams`".** Any owner
|
||||
id that is not a course — a *user* included — is recorded as a team's. The
|
||||
upload page never sends an owner for personal files (`data-owner=""`), so the
|
||||
server defaults to the creator and records `user`; send the user id and every
|
||||
later permission check dereferences a team that does not exist.
|
||||
- **The file permission service writes `refOwnerModel`** where the "shared with
|
||||
me" query reads `refPermModel`, so a share made through it never appears
|
||||
under Geteilte Dateien. The share-link flow patches `/files/{id}` directly.
|
||||
|
||||
## WebUntis is a fourth store, and holds the timetable
|
||||
|
||||
Schulcloud's `times` on `/api/v1/courses` are empty for this school, so nothing
|
||||
in Schulcloud says when a lesson happens, let alone that it was cancelled. That
|
||||
lives in **WebUntis**, a separate product with its own login. What this server
|
||||
uses is the API the Untis Mobile app uses, verified against
|
||||
`ags-erfurt.webuntis.com` on 2026-09-17.
|
||||
|
||||
- **One endpoint, JSON-RPC:** `POST /WebUntis/jsonrpc_intern.do?m=<method>&school=<school>&v=i3.2`,
|
||||
with `{"jsonrpc":"2.0","method":<method>,"params":[{…, "auth":{…}}]}`.
|
||||
- **`v` is not optional.** Omit it and the call fails with `-8998` carrying a
|
||||
Java `NullPointerException` from `getParameter`, which reads like a bug in
|
||||
the request body and is not.
|
||||
- Errors arrive with **HTTP 200** and an `error` member, so the body has to be
|
||||
checked before the status.
|
||||
- A plain `user-agent: schulcloud-mcp` is accepted; there is no need to
|
||||
impersonate the app.
|
||||
- **Authentication is a TOTP over a static base32 key**, no password and no
|
||||
session: `auth: { user, otp, clientTime }` on every call, where `otp` is the
|
||||
6-digit RFC 6238 code for the key from Profil → Freigaben → Untis Mobile.
|
||||
- **Send the code as a string.** One in ten starts with a zero, which a JSON
|
||||
number silently drops. Both shapes are accepted, so only the string is
|
||||
always right.
|
||||
- `-8504 bad credentials` = wrong key or user. `-8524 invalid client time` =
|
||||
the host's clock is off, which is its own failure and worth naming.
|
||||
- The response sets a `JSESSIONID`, but nothing needs it: each request
|
||||
authenticates itself, which is why there is no keepalive on this side.
|
||||
- **Methods that exist** (and are all this server may call — see the allowlist
|
||||
in `core/untis.ts`): `getUserData2017`, `getTimetable2017`,
|
||||
`getLessonTopic2017`, `getHomeWork2017`, `getMessagesOfDay2017`.
|
||||
`getClassregEvents2017` and `getSchoolyears2017` answer "Method not found";
|
||||
`getPeriodData2017` answers with empty objects for a student.
|
||||
- **`startDateTime` claims to be UTC and is not.** Lessons come back as
|
||||
`2026-09-21T08:00Z` and the school's time grid starts at 08:00 local, so the
|
||||
`Z` is decoration. `new Date(...)` would move every lesson by an hour or two,
|
||||
twice a year by a different amount; `splitLocal` takes the string apart
|
||||
instead.
|
||||
- **A substitution is two periods, not one changed period.** The original turns
|
||||
up with `is: ["CANCELLED"]` and the replacement beside it with
|
||||
`is: ["IRREGULAR"]`, same slot, different teacher. `orgId` on an element also
|
||||
exists (the room moves that way), so both have to be read. Statuses seen on a
|
||||
real account: `REGULAR`, `CANCELLED`, `IRREGULAR`.
|
||||
- **`getTimetable2017` carries the whole master data with every answer** —
|
||||
subjects, teachers with full names, rooms, classes, every holiday of six
|
||||
school years. There is a `masterDataTimestamp` delta protocol; its removal
|
||||
semantics are unverified, so this server asks for the full set and caches it
|
||||
for a few hours.
|
||||
- **Homework hangs off the periods too.** `getHomeWork2017` filters by the
|
||||
homework's own dates, so a window ending today shows nothing due tomorrow and
|
||||
nothing set last month — and the same items appear inline on the timetable's
|
||||
periods, where they need no second call.
|
||||
- **`getLessonTopic2017` takes `periodId`, singular.** It answers with
|
||||
`previousTopics`: what the earlier lessons of that series actually covered,
|
||||
from the class register. A `periodIds` array is rejected as "period 0 not
|
||||
found".
|
||||
- **It answers per *series*, so one call covers a term.** The entries come
|
||||
back with their own `periodId` and date, which is what lets a range of
|
||||
lessons be reconstructed from a handful of calls rather than one per period:
|
||||
take the distinct `lessonId`s in the range, ask about the **latest**
|
||||
`periodId` of each, and merge the answers back onto the periods by id.
|
||||
Asking about the earliest period of a series instead reaches none of its
|
||||
history, because "previous" is relative to the period given.
|
||||
`core/untis-history.ts` is that walk, and it is what puts the class register
|
||||
into the search index.
|
||||
- **The exam module is unused at this school**, so `getExams2017` is empty and
|
||||
`period.exam` is null. Announced tests are typed into the period's **info
|
||||
text** instead ("LF10: Leistungskontrolle agile Softwareentwicklung …"), which
|
||||
makes `text.info` the most valuable field in the payload rather than a
|
||||
footnote.
|
||||
- **A day with no lessons is not necessarily a holiday.** At a vocational school
|
||||
the weeks spent in the company simply have no periods, and `holidays` says
|
||||
nothing about them. Reporting "Ferien" there would be wrong; so would an
|
||||
empty answer.
|
||||
- **The key can write.** This account's `rights` are `CLASSREGISTER`,
|
||||
`R_MY_ABSENCES`, `W_OWN_ABSENCE`, `R_OFFICEHOURS` — the mobile API can report
|
||||
an absence for the user. Nothing here does, and the allowlist is what
|
||||
guarantees it.
|
||||
|
||||
## Re-verifying after an upstream release
|
||||
|
||||
|
||||
150
docs/AUTH.md
150
docs/AUTH.md
@@ -77,6 +77,37 @@ and this server cannot end each other.
|
||||
With no tab attached, the keepalive holds the session to the 30-day hard expiry,
|
||||
and replacing the token becomes the monthly chore it looked like at first.
|
||||
|
||||
## Replacing the token without a restart
|
||||
|
||||
`schulcloud token set` (paste at a hidden prompt, or pipe it in) and the
|
||||
server's `/token` page both send a fresh token to `PUT /api/token`, behind the
|
||||
bearer check. `core/session-token.ts` then:
|
||||
|
||||
1. **Cleans the paste.** A bare value, `jwt=…; Path=/`, quotes and newlines all
|
||||
work.
|
||||
2. **Checks before it swaps.** A malformed or expired token is refused without
|
||||
asking Schulcloud; otherwise `GET /api/v3/me` must succeed *with the new
|
||||
token*, and for the same `userId` the current one carries. A refused token
|
||||
changes nothing. Switching accounts stays a deliberate act — change
|
||||
`TSC_JWT_COOKIE` and restart.
|
||||
3. **Swaps it in place.** Every request reads `config.jwt` at the moment it is
|
||||
sent, so the next one uses the new token; nothing caches a copy.
|
||||
4. **Restarts the keepalive**, which stopped for good on a 401. Its pings carry
|
||||
a generation number, so one still in flight with the old token cannot stop
|
||||
the new cycle when its 401 arrives.
|
||||
5. **Saves it** to `STATE_DIR` (0600, written beside itself and renamed). At
|
||||
startup the newer of the saved token and `TSC_JWT_COOKIE` wins, by `exp` —
|
||||
unless they belong to different accounts, when the environment does.
|
||||
|
||||
The token appears in no log line and no response; `/api/token` reports only
|
||||
the expiry, where the token came from, and the keepalive's state. The claims
|
||||
are decoded, never verified — Schulcloud verifies, this only reads dates.
|
||||
|
||||
The cookie is **HttpOnly**, so no script — no bookmarklet, no page on another
|
||||
origin — can read it out of the browser. The DevTools copy is the one manual
|
||||
step, and it cannot be automated away short of a headless browser holding the
|
||||
Schulportal password (see below).
|
||||
|
||||
### There is no longer window available
|
||||
|
||||
`config/default.schema.json` documents `JWT_EXTENDED_TIMEOUT_SECONDS`
|
||||
@@ -131,6 +162,38 @@ says so immediately:
|
||||
[schulcloud-mcp] keepalive: token rejected (401). The session is gone …
|
||||
```
|
||||
|
||||
## WebUntis: a second credential, of a different kind
|
||||
|
||||
The timetable is not in Schulcloud, so the server talks to WebUntis as well —
|
||||
and that side authenticates far more comfortably. In WebUntis, open **Profil →
|
||||
Freigaben → Untis Mobile → QR-Code**. The dialog shows four values, which go
|
||||
into `.env` as `UNTIS_SERVER` (its "Url"), `UNTIS_SCHOOL`, `UNTIS_USER` and
|
||||
`UNTIS_SECRET` (its "Schlüssel"). The school number shown there is not used.
|
||||
|
||||
Why this is the right credential for a server:
|
||||
|
||||
- **No password.** The key is what the Untis Mobile app is given, and it works
|
||||
even where the WebUntis login goes through the school's SSO.
|
||||
- **Nothing to keep alive.** Every request carries a fresh time-based code
|
||||
derived from the key, so there is no session to refresh and nothing that dies
|
||||
when the Pi is off for a day. Unlike the Schulcloud token, this needs no
|
||||
monthly chore: the key stays valid until you replace it.
|
||||
- **Revocable on its own.** Generating a new key in that dialog invalidates the
|
||||
old one, and it has nothing to do with your password.
|
||||
|
||||
Two things to know:
|
||||
|
||||
- **The host's clock matters.** A time-based code from a drifting clock is
|
||||
refused with `-8524 invalid client time`, which the tools report as such. Any
|
||||
Pi with working NTP is fine.
|
||||
- **The key is not read-only — the server is.** It can do what the app can, and
|
||||
on this account that includes reporting an absence (`W_OWN_ABSENCE`). Untis'
|
||||
API is JSON-RPC, where reads are POSTs too, so "GET only" cannot be the
|
||||
guarantee here as it is for Schulcloud. Instead `core/untis.ts` carries an
|
||||
allowlist of five read methods and refuses everything else; `test/untis.test.ts`
|
||||
holds it to that. Replacing `UNTIS_SECRET` needs a restart — there is no live
|
||||
swap for it, because it does not expire.
|
||||
|
||||
## Why not username + password
|
||||
|
||||
The instance's login redirects to Keycloak (realm `TIS`) with a `redirect_uri`
|
||||
@@ -145,30 +208,93 @@ approach is the right trade: one manual step a month against re-implementing an
|
||||
OAuth client whose secret we cannot hold. If that monthly step ever becomes
|
||||
unacceptable, the honest options are a service account issued by the school's
|
||||
IDM, or driving the Keycloak login with a headless browser — not a
|
||||
reimplementation of the code exchange. If this ever needs to be unattended, the honest options are a service
|
||||
account issued by the school's IDM, or a headless browser login — not a
|
||||
reimplementation of the Keycloak exchange.
|
||||
reimplementation of the code exchange. A headless browser would have to hold
|
||||
the Schulportal password, which unlocks far more than this account's school
|
||||
files, so it is not done here.
|
||||
|
||||
## Protecting this server's own endpoint
|
||||
|
||||
Distinct from the above, and just as important. The MCP endpoint is reachable
|
||||
from the public internet by construction: Claude's connectors call it from
|
||||
Anthropic's cloud, not from your machine. It is protected by `MCP_AUTH_TOKEN`,
|
||||
a shared secret checked in constant time on every `/mcp` request
|
||||
(`src/http/auth.ts`), accepted as either `Authorization: Bearer …` or
|
||||
`X-Api-Key`. `/healthz` is deliberately open and reveals nothing.
|
||||
a shared secret checked in constant time on every `/mcp` and `/api` request
|
||||
(`src/http/auth.ts`). It is accepted as `Authorization: Bearer …`, as a bare
|
||||
`Authorization` value — claude.ai sends a header exactly as typed — or as
|
||||
`X-Api-Key` or `X-Auth-Token`. `/healthz` is deliberately open and reveals
|
||||
nothing.
|
||||
|
||||
Generate one with `openssl rand -hex 32`. If it is unset the server logs a loud
|
||||
warning and serves unauthenticated — only acceptable bound to localhost.
|
||||
|
||||
Rotating it: change `MCP_AUTH_TOKEN` in `.env`, restart the container, update
|
||||
the connector in Claude. Nothing else stores it.
|
||||
Rotating it: change `MCP_AUTH_TOKEN` in `.env`, recreate the container, and
|
||||
update Claude Code and the CLI (`schulcloud login`). Nothing else stores it.
|
||||
|
||||
### The connector token, for claude.ai
|
||||
|
||||
claude.ai sends a request header whose value it stores, so its token is a
|
||||
credential held by a third party. `MCP_CONNECTOR_TOKEN` gives it one of its
|
||||
own: accepted on `/mcp` alone, refused on `/api` — which can replace the
|
||||
Schulcloud token and stream the file mirror — and rotated without touching
|
||||
Claude Code or the CLI. `config.ts` requires at least 32 characters, a value
|
||||
different from `MCP_AUTH_TOKEN`, and `MCP_AUTH_TOKEN` itself, so it can never
|
||||
leave `/api` unguarded; its errors never echo a value. All accepted tokens are
|
||||
compared in full, so the timing does not tell which one matched.
|
||||
|
||||
### The secret path, for clients without request headers
|
||||
|
||||
For a client that can send no header, `MCP_PATH_SECRET` opens another way in:
|
||||
`/<secret>/mcp`, with no token at all. The path is the credential there. `http/auth.ts` compares it in constant time and answers a
|
||||
wrong one with the same 404 as any unknown path; `config.ts` insists on at
|
||||
least 32 URL-safe characters and never echoes the value; the server never logs
|
||||
request paths; and `deploy/Caddyfile.snippet` rewrites the segment before an
|
||||
access log entry is written. Rotating it means a new value, a recreated
|
||||
container, and re-adding the connector. Prefer the connector token wherever a
|
||||
header can be sent: a URL is copied into more places than a header is.
|
||||
|
||||
### The app password, for a person
|
||||
|
||||
`WEB_PASSWORD` is unlike every other credential here: it is typed by a human, on
|
||||
a phone, in a lesson. That single fact drives its whole design.
|
||||
|
||||
- **It is a passphrase, not a token.** `config.ts` insists on 12 characters and
|
||||
nothing else; demanding punctuation would buy little next to length, and the
|
||||
failure mode of a fussy rule is a shorter password, not a better one.
|
||||
- **It is never stored in the clear.** scrypt (N=16384) at startup; a login
|
||||
hashes the attempt and compares in constant time. The error states the rule
|
||||
and never echoes the value.
|
||||
- **Logins are rate-limited per address**, eight failures in fifteen minutes.
|
||||
Not optional: a password is guessable in a way a 32-byte token is not, and the
|
||||
scrypt cost that makes guessing expensive is itself a denial-of-service vector
|
||||
without a limiter in front of it.
|
||||
- **The session is a signed cookie**, `HttpOnly` and `SameSite=Strict` — the
|
||||
latter standing in for CSRF tokens, since nothing links into the app from
|
||||
anywhere else. 30 days, because the alternative is a login screen at the start
|
||||
of a lesson.
|
||||
- **The signing key is derived from the password**, so changing it invalidates
|
||||
every session that exists. No second secret, nothing to store, and the
|
||||
behaviour anyone changing a password already expects.
|
||||
- **The cookie opens `/api` and not `/mcp`.** A session *is* the user, and the
|
||||
app is built on `/api` — but nothing in a browser speaks MCP, and a surface
|
||||
that is not needed is not offered.
|
||||
|
||||
Unset, the app is not served at all. A login screen that no password can open is
|
||||
worse than no page, because it looks like a way in.
|
||||
|
||||
## Blast radius
|
||||
|
||||
Every path in this server is a `GET`, including the `api_get` escape hatch,
|
||||
which rejects anything not starting with `/api/` and anything carrying a scheme
|
||||
or host. Someone who obtained both the endpoint URL and `MCP_AUTH_TOKEN` could
|
||||
read this account's Schulcloud data; they could not post, submit, delete, or
|
||||
otherwise act as the user. Keep it that way — adding a single write tool would
|
||||
change that property entirely.
|
||||
or host. Someone who obtained both the endpoint URL and `MCP_AUTH_TOKEN` — or
|
||||
the connector token, the secret MCP path, or the app password — could read this
|
||||
account's Schulcloud data; they could not
|
||||
post, submit, delete, or otherwise act as the user. With `MCP_AUTH_TOKEN` they
|
||||
could also call `PUT /api/token`, but it accepts only a live token for the same
|
||||
account, so the most it can do is hand the server a session the owner already
|
||||
has. Keep it that way — adding a single write tool would change that property
|
||||
entirely.
|
||||
|
||||
The app password and `MCP_AUTH_TOKEN` additionally reach the notes: they can
|
||||
read, write and overwrite files under `NOTES_DIR`, and nothing outside it —
|
||||
`safeComponent` and `resolveWithin` are what make that a property rather than a
|
||||
hope. That is the only write anywhere in this server, and it touches the user's
|
||||
own files, never Schulcloud. `NOTES_READONLY=1` removes even that.
|
||||
|
||||
101
docs/CLI.md
101
docs/CLI.md
@@ -39,13 +39,114 @@ schulcloud ls [--course <id>] [--long]
|
||||
schulcloud get <fileId> [--out <path>]
|
||||
schulcloud sync [--dry-run] [--full] [--prune] [--dir <path>] [--jobs <n>]
|
||||
schulcloud refresh [--course <id>] [--force]
|
||||
schulcloud note ... the notes you take in class — see below
|
||||
```
|
||||
|
||||
`ls --long` prints file ids, which is what `get` takes.
|
||||
|
||||
`--course` accepts a **course or a room id** — rooms ("Räume") are mirrored
|
||||
alongside courses, with their files under the room's name rather than a course's.
|
||||
|
||||
`refresh` asks the server to re-read Schulcloud. Pass `--course` when you know
|
||||
what changed: that is a handful of requests, where a full re-crawl reads every
|
||||
course. The server refuses a repeat within a minute unless you pass `--force`.
|
||||
A full re-crawl can take many minutes — the first one downloads every file,
|
||||
file-manager folders included — so `refresh` starts it and then polls the
|
||||
server's status, printing a note every half minute, rather than holding one
|
||||
request open (which Node's fetch abandons after five minutes).
|
||||
|
||||
### The server's Schulcloud token (`token`)
|
||||
|
||||
```
|
||||
schulcloud token when it expires, and whether the session is alive
|
||||
schulcloud token set hand the server a fresh one
|
||||
```
|
||||
|
||||
The monthly chore, with no restart and no `.env` edit:
|
||||
|
||||
1. Open a **private window** and log in to Schulcloud.
|
||||
2. DevTools → Application (Firefox: Storage) → Cookies → `jwt`: copy the value.
|
||||
3. `schulcloud token set` and paste it at the prompt. The input is hidden.
|
||||
4. **Close the private window.** Left open, it logs the token out about two
|
||||
hours after login (docs/AUTH.md).
|
||||
|
||||
Piping works too — `wl-paste | schulcloud token set` — and a pasted cookie
|
||||
line such as `jwt=…; Path=/` is cleaned up. The token is never a command-line
|
||||
argument, so it cannot end up in shell history.
|
||||
|
||||
The server checks the token with Schulcloud before swapping it in, so a bad
|
||||
paste changes nothing. It refuses a token that is malformed, expired, already
|
||||
logged out, or for a different account. A replacement is saved on the server
|
||||
(`STATE_DIR`), so a restart keeps it, and the keepalive picks it up at once.
|
||||
The same form lives at `https://<server>/token` for when no terminal is at hand.
|
||||
The cookie is HttpOnly, so no bookmarklet can read it for you; the DevTools
|
||||
copy is the step that remains.
|
||||
|
||||
### The file manager (`fs`)
|
||||
|
||||
The Schulcloud file manager ("Dateien") — Persönliche, Kurs-, Team- and
|
||||
Geteilte Dateien — browsed like a filesystem, live:
|
||||
|
||||
```
|
||||
schulcloud fs ls [path] [--long]
|
||||
schulcloud fs tree [path] [--depth <n>] [--max-folders <n>]
|
||||
schulcloud fs find <name> [--path <path>] [--type file|folder] [--long]
|
||||
schulcloud fs get <path> [--out <path>] [--force] [--jobs <n>]
|
||||
```
|
||||
|
||||
```console
|
||||
$ schulcloud fs ls /courses
|
||||
$ schulcloud fs tree "/courses/FIA24B - SK (Rh)"
|
||||
$ schulcloud fs find "*Erben*" --path /courses
|
||||
$ schulcloud fs get "/courses/FIA24B - SK (Rh)/02_Erbrecht" --out ~/Erbrecht
|
||||
```
|
||||
|
||||
The tree is `/my`, `/courses/<course>`, `/teams/<team>` and `/shared`; the
|
||||
German names ("/Kurs-Dateien") work too. Names may contain `/` — course names
|
||||
often do — and still resolve; any segment may also be an id from `--long`.
|
||||
|
||||
`fs find` matches any part of a name, or, given `*` or `?`, the whole name as
|
||||
`find -name` does. `fs get` on a folder downloads everything below it, keeps
|
||||
the structure, and skips files already present at the same size — so re-running
|
||||
it resumes. Each folder is one page load on the server, so large trees take a
|
||||
while.
|
||||
|
||||
`sync` mirrors these files too, under `<course>/Kurs-Dateien/…`,
|
||||
`Persönliche Dateien/…`, `Team-Dateien/<team>/…` and `Geteilte Dateien/`, once
|
||||
the server's index includes them (`INDEX_FILE_MANAGER`, on by default).
|
||||
|
||||
### Your own lesson notes (`note`)
|
||||
|
||||
The notes you take in class, which Claude reads as context. The full story is in
|
||||
[NOTES.md](NOTES.md); these are the commands.
|
||||
|
||||
```
|
||||
schulcloud note ls [--subject <name>] [--since <date>] [--until <date>] [--long]
|
||||
schulcloud note show <path>
|
||||
schulcloud note add --title <title> [--subject <name>] [--date <date>]
|
||||
[--tags a,b] [--append] text on stdin, or --text
|
||||
schulcloud note import <export.ndjson> [--subject <name>] [--out <dir>] [--dry-run]
|
||||
```
|
||||
|
||||
```console
|
||||
$ pbpaste | schulcloud note add --title "Subnetting" --subject LF07
|
||||
Saved LF07/2026-09-16 Subnetting.md
|
||||
|
||||
$ schulcloud note ls --subject Deutsch --since 2026-09-01
|
||||
2026-09-22 [Deutsch] Sprachanalyse
|
||||
2026-09-15 [Deutsch] Erörterung — Aufbau
|
||||
|
||||
$ schulcloud note show "Deutsch/2026-09-15 Erörterung.md"
|
||||
```
|
||||
|
||||
`note add` reads the note from stdin, so it comes just as easily from a
|
||||
clipboard, an editor or another command; `--append` adds to the note already
|
||||
written for that subject and day rather than starting a second one.
|
||||
|
||||
`note import` takes the file `scripts/export-apple-notes.js` writes on a Mac.
|
||||
`--dry-run` shows what it would do and `--out <dir>` writes the Markdown
|
||||
locally instead of sending it to the server — the only note command that needs
|
||||
no server at all.
|
||||
|
||||
## How sync works
|
||||
|
||||
|
||||
264
docs/DEPLOY-NOTES.md
Normal file
264
docs/DEPLOY-NOTES.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Rolling out the notes app
|
||||
|
||||
A runbook for putting the notes feature and the `/app` web app onto a
|
||||
deployment that is already running. [PI.md](PI.md) is the first-time setup;
|
||||
this is the upgrade, and it assumes the server is live and healthy.
|
||||
|
||||
Read [NOTES.md](NOTES.md) first if you have not: **step 2 is a decision you
|
||||
should not make twice**, because changing it later means moving files by hand.
|
||||
|
||||
## What arrives
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Your own lesson notes** | A directory of Markdown files the server reads, indexes and searches beside Schulcloud and WebUntis. Three tools: `list_notes`, `get_note`, `add_note`. |
|
||||
| **The app at `/app`** | A login, a day-at-a-time notes editor with a formatting toolbar, and a settings page that replaces the Schulcloud token. Only served when `WEB_PASSWORD` is set. |
|
||||
| **The WebUntis class register** | `untis_lesson_topics` now takes a subject as well as a period id, and `UNTIS_HISTORY_DAYS` of "what was actually taught" goes into the search index. |
|
||||
|
||||
Nothing here changes Schulcloud or WebUntis: both stay read-only. The notes
|
||||
directory is the only thing this server writes to, and it is yours.
|
||||
|
||||
## Before you start
|
||||
|
||||
- **Ten minutes**, plus however long a full crawl takes (the first one with a
|
||||
class register adds a few dozen WebUntis requests; it runs in the background).
|
||||
- **No Caddy change.** `/app` is served by the same container on the same port,
|
||||
and the snippet already proxies everything. One line was added to
|
||||
`deploy/Caddyfile.snippet` as a comment about `X-Forwarded-Proto`; if your
|
||||
site block predates it, check that nothing strips that header — the session
|
||||
cookie's `Secure` flag depends on it.
|
||||
- **No database migration.** Notes and class-register entries are new node kinds
|
||||
in a column that is plain `TEXT`.
|
||||
- **No compose change on the Pi.** `deploy/docker-compose.pi.yml` overrides only
|
||||
the image, the database URL and the network, so the `notes` volume and
|
||||
`NOTES_DIR=/data/notes` come from `docker-compose.yml` unchanged.
|
||||
|
||||
## 1. Publish the image
|
||||
|
||||
On a development machine, from a clean checkout of `main`:
|
||||
|
||||
```bash
|
||||
npm run publish-image
|
||||
```
|
||||
|
||||
It builds arm64 and amd64 and pushes `latest` plus the commit id. If you pin
|
||||
`SCHULCLOUD_MCP_TAG`, note the short commit it prints — you need it in step 3.
|
||||
|
||||
## 2. Decide where the notes live
|
||||
|
||||
**Do this before anything writes a note.** Both options work; moving between
|
||||
them afterwards means moving files.
|
||||
|
||||
**A — the volume (default, nothing to do).** `docker-compose.yml` already
|
||||
declares a `notes` volume at `/data/notes`. The app and the CLI write to it,
|
||||
`add_note` writes to it, and that is the whole story. Choose this if you will
|
||||
write notes in the app and nowhere else.
|
||||
|
||||
**B — a directory you sync.** Choose this to *also* write notes from a phone or
|
||||
a laptop in an editor — Obsidian, iA Writer, a git repo. Edit
|
||||
`docker-compose.yml` on the Pi:
|
||||
|
||||
```yaml
|
||||
# under schulcloud-mcp:
|
||||
volumes:
|
||||
- mirror:/data/mirror
|
||||
- state:/data/state
|
||||
- /home/pi/Notizen:/data/notes # was: notes:/data/notes
|
||||
```
|
||||
|
||||
```bash
|
||||
mkdir -p /home/pi/Notizen
|
||||
```
|
||||
|
||||
The container runs unprivileged and `read_only`, so that directory must be
|
||||
writable by the container's user — if the logs show `EACCES` for `/data/notes`,
|
||||
see Troubleshooting. Then point Syncthing, Nextcloud or `git` at it. The server
|
||||
does not care which; new files are picked up by the next full crawl.
|
||||
|
||||
## 3. Configure
|
||||
|
||||
On the Pi, in `/opt/schulcloud-mcp`:
|
||||
|
||||
```bash
|
||||
cd /opt/schulcloud-mcp
|
||||
git pull
|
||||
```
|
||||
|
||||
Add the app password to `.env`. It is the only credential here a person types,
|
||||
so it is a passphrase rather than a token — **at least 12 characters, and three
|
||||
or four words is the right shape**:
|
||||
|
||||
```bash
|
||||
cat >> .env <<'EOF'
|
||||
|
||||
# --- the notes app ---
|
||||
WEB_PASSWORD=change-this-to-three-or-four-words
|
||||
EOF
|
||||
```
|
||||
|
||||
Optional, on the same pass:
|
||||
|
||||
| Setting | |
|
||||
|---|---|
|
||||
| `UNTIS_HISTORY_DAYS` | How far back to index the class register. Default 180; `0` turns it off. Only does anything with `UNTIS_*` configured. |
|
||||
| `NOTES_READONLY=1` | Refuse every write. `list_notes` and `get_note` still work, `add_note` and the app's save do not. Right when the notes are synced in and should have exactly one writer. |
|
||||
| `SCHULCLOUD_MCP_TAG` | If you pin images, set it to the commit from step 1. |
|
||||
|
||||
**Put `WEB_PASSWORD` in your password manager now.** The server never prints it,
|
||||
and changing it logs out every session.
|
||||
|
||||
## 4. Start it
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
docker compose logs --tail 20 schulcloud-mcp
|
||||
```
|
||||
|
||||
The startup line names what is on. With everything configured it ends
|
||||
`… keepalive every 30min, index every 6h`; the app and the notes are not named
|
||||
there, so verify them in the next step rather than reading the log for them.
|
||||
|
||||
## 5. Verify
|
||||
|
||||
From anywhere:
|
||||
|
||||
```bash
|
||||
curl -s https://mcp.example.org/healthz
|
||||
# {"status":"ok","sessions":0,"index":"on"}
|
||||
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://mcp.example.org/app/
|
||||
# 200 ← the app is served; 404 means WEB_PASSWORD is not set
|
||||
```
|
||||
|
||||
Then open `https://mcp.example.org/app/` in a browser and log in.
|
||||
|
||||
- **Notizen** should show today, and — if WebUntis is configured — today's
|
||||
lessons as headings, with times, teacher and room. "Kein Unterricht an diesem
|
||||
Tag" on a company-phase week or a weekend is correct, not a fault.
|
||||
- Type a line and wait two seconds. The status line should read
|
||||
**Gespeichert HH:MM**.
|
||||
- **Einstellungen** should show the Schulcloud token's remaining days and the
|
||||
index's state.
|
||||
|
||||
On a phone, add it to the home screen — it has a manifest and opens standalone.
|
||||
|
||||
Check the file landed where you meant it to:
|
||||
|
||||
```bash
|
||||
docker compose exec schulcloud-mcp ls -R /data/notes
|
||||
# 2026/2026-09-19.md
|
||||
```
|
||||
|
||||
## 6. Bring the old notes in
|
||||
|
||||
If you have notes in Apple Notes, migrate them now — see
|
||||
[NOTES.md](NOTES.md#migrating-out-of-apple-notes). Briefly, on the Mac:
|
||||
|
||||
```bash
|
||||
osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson
|
||||
schulcloud note import notes.ndjson --dry-run # look first
|
||||
schulcloud note import notes.ndjson
|
||||
```
|
||||
|
||||
Import **once**. Re-running creates second copies, because the importer cannot
|
||||
tell an edited note from a new one with the same title.
|
||||
|
||||
## 7. Index them
|
||||
|
||||
Notes and the class register are only read by a **full** crawl. One runs on the
|
||||
timer (`CRAWL_INTERVAL_MS`, six hours by default), or force one now:
|
||||
|
||||
```bash
|
||||
schulcloud refresh --force
|
||||
```
|
||||
|
||||
Expect it to take minutes; the CLI polls and prints progress. When it finishes:
|
||||
|
||||
```bash
|
||||
schulcloud status
|
||||
```
|
||||
|
||||
Then ask Claude something only the new sources can answer — *"what did I write
|
||||
down in Deutsch last week?"* or *"what did we actually cover in LF07 this
|
||||
term?"* — and check the answer names your note or the class register as its
|
||||
source.
|
||||
|
||||
## Backups, which now matter more
|
||||
|
||||
**The notes are the only irreplaceable thing this server holds.** Everything
|
||||
else it stores is a copy of something upstream; a note you took in a lesson is
|
||||
not, and nothing can rebuild it.
|
||||
|
||||
| What | Needed? |
|
||||
|---|---|
|
||||
| `.env` | **Yes** — every secret, `WEB_PASSWORD` included. Encrypted only. |
|
||||
| **`schulcloud-mcp_notes`** (option A) | **Yes. Nothing can regenerate these.** |
|
||||
| **Your synced directory** (option B) | **Yes**, unless the sync tool already keeps versioned copies elsewhere — and check that it does, rather than assuming. |
|
||||
| Postgres | Optional: a crawl rebuilds it. |
|
||||
| `schulcloud-mcp_mirror` | No — re-downloaded by the next crawl. |
|
||||
| `schulcloud-mcp_state` | No — a replaced token, expiring within 30 days anyway. |
|
||||
|
||||
With the volume (option A):
|
||||
|
||||
```bash
|
||||
docker run --rm -v schulcloud-mcp_notes:/notes:ro -v "$PWD":/out alpine \
|
||||
tar czf /out/notizen-$(date +%F).tar.gz -C /notes .
|
||||
```
|
||||
|
||||
They are small — a school year of notes is a few megabytes — so back them up
|
||||
often and keep the old copies.
|
||||
|
||||
## Rolling back
|
||||
|
||||
The feature adds no migration and no incompatible state, so going back is the
|
||||
ordinary downgrade:
|
||||
|
||||
```bash
|
||||
# in .env
|
||||
SCHULCLOUD_MCP_TAG=<previous commit>
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
The old image ignores `WEB_PASSWORD` and `NOTES_DIR` and serves no `/app`. **The
|
||||
notes volume is untouched** — the files stay, and the newer image picks them up
|
||||
again unchanged. The only thing lost while rolled back is the ability to read or
|
||||
write them.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| `/app/` answers 404 | `WEB_PASSWORD` not set, or the container not recreated since it was | `grep ^WEB_PASSWORD= .env`, then `docker compose up -d --force-recreate schulcloud-mcp` |
|
||||
| The server refuses to start, log names `WEB_PASSWORD` | Shorter than 12 characters | Use a longer passphrase |
|
||||
| Login says the password is wrong, and it is not | `.env` is read at container creation | `docker compose up -d --force-recreate schulcloud-mcp` |
|
||||
| Logged out constantly, or the login "does nothing" | The session cookie is marked `Secure` and the connection is not HTTPS, or `X-Forwarded-Proto` is stripped | Reach it over HTTPS; check the Caddy site does not strip that header |
|
||||
| `Zu viele Fehlversuche` | The per-address rate limiter, eight failures in fifteen minutes | Wait it out; it is doing its job |
|
||||
| The editor says the server keeps no notes | `NOTES_DIR` unset on the server | It is set by `docker-compose.yml`; check `COMPOSE_FILE` in `.env` still lists it first |
|
||||
| `Der Server nimmt keine Änderungen an` | `NOTES_READONLY` is on | Remove it and recreate the container |
|
||||
| Saves refused as a conflict, repeatedly | The note is being changed elsewhere — a sync tool, another device | Choose a version in the banner; if a sync tool keeps rewriting the file, it is fighting the app |
|
||||
| `EACCES` for `/data/notes` in the logs | A bind-mounted directory the container's user cannot write | `sudo chown -R 1000:1000 /home/pi/Notizen` (match the image's user), then recreate |
|
||||
| The toolbar is there, the text stays plain | The browser blocked `editor.js` or `markdown.js` | Check the console; both must be served from `/app/`, and `app.js` must load as `type="module"` |
|
||||
| A note opens in the Markdown view by itself, with a hint | It holds formatting the formatted view cannot keep unchanged | Nothing is wrong and nothing was lost; edit it there, or simplify the note |
|
||||
| Notes exist but `search` cannot find them | Only a full crawl reads them | `schulcloud refresh --force` |
|
||||
| `search` finds a day note but names no subject | The lesson headings were rewritten past recognition | Keep `## 1. Deutsch …`; the leading number and the subject are what the index reads |
|
||||
| `untis_lesson_topics` with a subject finds nothing | The subject code differs from what you typed | Check it against `untis_timetable`; the register uses the school's own codes |
|
||||
|
||||
## Security notes for this rollout
|
||||
|
||||
- `WEB_PASSWORD` is the first credential here that a human types, so the first
|
||||
that can be guessed. It is hashed with scrypt at startup and never stored,
|
||||
compared or logged in the clear, and failed logins are rate-limited per
|
||||
address — but **length is what actually protects it**.
|
||||
- The session cookie opens `/api`, which can read your coursework and replace
|
||||
the Schulcloud token. It does not open `/mcp`. Treat a login on a shared
|
||||
device as you would treat the token.
|
||||
- Changing `WEB_PASSWORD` invalidates every session, because the signing key is
|
||||
derived from it. That is the revocation mechanism: change it, recreate the
|
||||
container, log in again.
|
||||
- The write surface is bounded to `NOTES_DIR` by `safeComponent` and
|
||||
`resolveWithin` — the same two functions that stop a hostile Schulcloud
|
||||
filename escaping the file mirror. Schulcloud and WebUntis remain read-only.
|
||||
@@ -1,5 +1,8 @@
|
||||
# Deployment
|
||||
|
||||
This page explains the moving parts and why each is there. For the steps in
|
||||
order, on the Pi, follow [PI.md](PI.md).
|
||||
|
||||
## The shape of it
|
||||
|
||||
```
|
||||
@@ -13,13 +16,21 @@ schulcloud CLI ─────┘ └─ Caddy
|
||||
└──▶ schulcloud-thueringen.de
|
||||
```
|
||||
|
||||
Both front ends use the same hostname and the same bearer token. `/mcp` speaks
|
||||
MCP; `/api` serves the CLI's manifest, file bytes and re-crawl requests.
|
||||
Both front ends use the same hostname. `/mcp` speaks MCP; `/api` serves the
|
||||
CLI's manifest, file bytes, re-crawl requests and token replacement; `/token` is
|
||||
a page for pasting a fresh Schulcloud token.
|
||||
|
||||
Claude's custom connectors call the endpoint from Anthropic's cloud, so it must
|
||||
be publicly reachable over real TLS — a localhost tunnel or self-signed cert
|
||||
will not do. The VPS provides the public address; Caddy on the Pi terminates
|
||||
TLS and obtains the certificate.
|
||||
Claude's custom connectors call the endpoint from Anthropic's cloud
|
||||
(`160.79.104.0/21`), so it must be publicly reachable over real TLS — a
|
||||
localhost tunnel or self-signed cert will not do. The VPS provides the public
|
||||
address; Caddy on the Pi terminates TLS and obtains the certificate. **Keep the
|
||||
VPS forwarding raw TCP** rather than terminating TLS itself: then it never sees
|
||||
a request, whose header or path carries a credential (below).
|
||||
|
||||
**The Pi must stay up.** More than two hours offline ends the Schulcloud session
|
||||
however long the token has left — a laptop that sleeps overnight loses it every
|
||||
night. A replacement token fixes that without a restart (see *Replacing the
|
||||
Schulcloud token*), but an always-on host is what avoids needing one.
|
||||
|
||||
The container publishes no host port. Caddy reaches it over the shared Docker
|
||||
network, so the only way in from the internet is through Caddy and then through
|
||||
@@ -27,22 +38,21 @@ this server's bearer check.
|
||||
|
||||
## First deploy
|
||||
|
||||
[PI.md](PI.md) steps 1–5: log in to `registry.mc02.dev`, clone to
|
||||
`/opt/schulcloud-mcp` for the compose files, write `.env` (the Pi's
|
||||
`COMPOSE_FILE`, `CADDY_NETWORK` and `POSTGRES_PASSWORD`, generated secrets, the
|
||||
first Schulcloud token), then:
|
||||
|
||||
```bash
|
||||
git clone <this repo> /opt/schulcloud-mcp
|
||||
cd /opt/schulcloud-mcp
|
||||
|
||||
cp .env.example .env
|
||||
# Fill in TSC_URL and TSC_JWT_COOKIE (see docs/AUTH.md), then:
|
||||
openssl rand -hex 32 # → MCP_AUTH_TOKEN
|
||||
|
||||
docker compose up -d --build
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
docker compose logs -f schulcloud-mcp
|
||||
```
|
||||
|
||||
Expect:
|
||||
|
||||
```
|
||||
[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://… , auth enabled, keepalive every 30min
|
||||
[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://… , auth enabled (plus secret MCP path), token from environment, 29 day(s) left, keepalive every 30min, index every 6h
|
||||
```
|
||||
|
||||
`auth DISABLED` there means `MCP_AUTH_TOKEN` is empty — fix it before exposing
|
||||
@@ -52,39 +62,29 @@ will run but every tool will fail.
|
||||
|
||||
## Joining the existing Caddy
|
||||
|
||||
The Pi already runs Caddy and PostgreSQL in a Compose project. This server needs
|
||||
neither a database nor its own Caddy — only a network it shares with the
|
||||
existing one.
|
||||
The Pi already runs Caddy in a container. This server needs no Caddy of its own
|
||||
— only to sit on the same Docker network, so Caddy reaches it by name and no
|
||||
host port is published.
|
||||
|
||||
Find the network Caddy is on:
|
||||
|
||||
```bash
|
||||
docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' <caddy-container>
|
||||
```
|
||||
|
||||
Then in `docker-compose.yml`, set that name and mark it external:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
caddy:
|
||||
external: true
|
||||
name: <the network name you just found>
|
||||
```
|
||||
That is what `deploy/docker-compose.pi.yml` does, and the Pi's `.env` selects it
|
||||
with `COMPOSE_FILE=docker-compose.yml:deploy/docker-compose.pi.yml`, so plain
|
||||
`docker compose` commands pick it up and nothing tracked needs editing. It
|
||||
declares the Caddy network external under the name in `CADDY_NETWORK`. Setting
|
||||
`COMPOSE_FILE` also matters for what it leaves out: without it, Compose merges
|
||||
`docker-compose.override.yml`, which is for local development and publishes
|
||||
ports.
|
||||
|
||||
### Postgres
|
||||
|
||||
The index needs a database. On the Pi, use the existing PostgreSQL rather than
|
||||
the container in `docker-compose.yml` — create a database and user for it:
|
||||
The index gets its own Postgres container, on a network shared with this server
|
||||
and nothing else — not with Caddy, and not with whatever else is on Caddy's
|
||||
network. The Pi file builds `DATABASE_URL` from `POSTGRES_PASSWORD`. Migrations
|
||||
run at startup; the first creates `pg_trgm`.
|
||||
|
||||
```sql
|
||||
CREATE USER schulcloud WITH PASSWORD '…';
|
||||
CREATE DATABASE schulcloud OWNER schulcloud;
|
||||
```
|
||||
|
||||
Then set `DATABASE_URL` in `.env` and delete the `postgres` service from the
|
||||
compose file. Migrations run automatically at startup; `pg_trgm` is created by
|
||||
the first migration, which needs the database owner to be able to
|
||||
`CREATE EXTENSION`.
|
||||
An existing Postgres works as well: create a database and a user that owns it
|
||||
(it must be able to `CREATE EXTENSION`), point `DATABASE_URL` at it, and remove
|
||||
the `postgres` service and the `depends_on` that names it. Nothing requires
|
||||
sharing one.
|
||||
|
||||
Without `DATABASE_URL` the server still runs: search crawls live on every call
|
||||
and `/api` returns `503`. The startup log says which mode it is in.
|
||||
@@ -105,6 +105,9 @@ Two settings in that snippet matter and are easy to miss:
|
||||
connector hangs with no error.
|
||||
- **`read_timeout`/`write_timeout` of 300s** — a `search` call walks every
|
||||
course and can take tens of seconds. Caddy's defaults will cut it off.
|
||||
- **The `format filter` in `log`** — rewrites `/<secret>/mcp` before an access
|
||||
log entry is written. Without it every claude.ai request writes the secret to
|
||||
disk. Verified against Caddy 2.11: the entry reads `"uri":"/<secret>/mcp"`.
|
||||
|
||||
## Ports and DNS
|
||||
|
||||
@@ -122,6 +125,10 @@ curl -s https://mcp.example.org/healthz
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.org/mcp \
|
||||
-H 'content-type: application/json' -d '{}'
|
||||
# 401 ← the bearer check is live
|
||||
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.org/$(openssl rand -hex 32)/mcp \
|
||||
-H 'content-type: application/json' -d '{}'
|
||||
# 404 ← a wrong path secret looks like any unknown path
|
||||
```
|
||||
|
||||
If `/healthz` answers but `/mcp` returns 401 with a correct token, check that
|
||||
@@ -130,15 +137,134 @@ newline from a copy-paste.
|
||||
|
||||
## Connecting Claude
|
||||
|
||||
1. claude.ai → **Settings → Connectors → Add custom connector**.
|
||||
2. URL: `https://mcp.example.org/mcp`
|
||||
3. Under **Advanced settings**, add the bearer token as an authorization
|
||||
header. If your organisation has no header-auth field, the server also
|
||||
accepts the token as `X-Api-Key`.
|
||||
4. Enable the connector in a conversation via **+ → Add connectors**.
|
||||
### claude.ai — a request header with a token of its own
|
||||
|
||||
Ask *"which courses am I in?"* as a first check — that exercises auth, the
|
||||
Schulcloud token and the API in one call.
|
||||
claude.ai's *Add custom connector* dialog asks for a name and a URL first. Its
|
||||
authentication settings, **Request headers** among them, appear on the next
|
||||
step, once it has probed the URL.
|
||||
|
||||
1. Put `MCP_CONNECTOR_TOKEN=<openssl rand -hex 32>` in `.env` and recreate the
|
||||
container: `docker compose up -d --force-recreate schulcloud-mcp`. The
|
||||
startup line then says `(plus connector token)`.
|
||||
2. claude.ai → **Customize → Connectors → Add custom connector**. Name it, and
|
||||
give the URL `https://mcp.example.org/mcp`.
|
||||
3. On the next step keep **No sign-in**, which is what Claude detects, and add a
|
||||
request header: name `authorization`, value `Bearer <MCP_CONNECTOR_TOKEN>`,
|
||||
space included. A bare token, or the header `x-api-key`, works too.
|
||||
4. Enable it in a conversation via **+ → Connectors**.
|
||||
|
||||
Ask *"which courses am I in?"* as a first check — that exercises the header,
|
||||
the Schulcloud token and the API in one call.
|
||||
|
||||
**Why a token of its own.** claude.ai stores the header value, so the token is
|
||||
a credential held by a third party. `MCP_CONNECTOR_TOKEN` opens `/mcp`, the
|
||||
read-only tools, and is refused on `/api`, which can replace the Schulcloud
|
||||
token and stream the file mirror. It also rotates alone: set a new value,
|
||||
recreate the container, then remove the connector and add it again, because
|
||||
claude.ai cannot edit a stored header. Claude Code and the CLI are unaffected.
|
||||
|
||||
### Without request headers — a secret path
|
||||
|
||||
For a client that cannot send a header, the server can serve MCP at a path
|
||||
that is itself the secret:
|
||||
|
||||
1. Put `MCP_PATH_SECRET=<openssl rand -hex 32>` in `.env` and recreate the
|
||||
container. The startup line then says `(plus secret MCP path)` — never the
|
||||
secret itself.
|
||||
2. Give the client the URL `https://mcp.example.org/<secret>/mcp`, with no
|
||||
header and no sign-in.
|
||||
|
||||
**Know what this trades away.** The URL is now the credential, and Anthropic's
|
||||
connector documentation calls credentials in URLs a security vulnerability,
|
||||
because URLs end up in logs. This deployment keeps it out of its own: the
|
||||
server never logs request paths, the Caddy snippet rewrites the segment to
|
||||
`<secret>` in the access log, and a VPS forwarding raw TCP never sees it.
|
||||
Caddy's *error* log can still name the path if the container is down while
|
||||
claude.ai calls, and claude.ai stores the URL in its connector settings. There
|
||||
is no revocation short of a new secret: change `MCP_PATH_SECRET`, recreate the
|
||||
container, and add the connector again. Anyone holding the URL can read — never
|
||||
change — the account. A wrong secret answers 404, like any unknown path.
|
||||
|
||||
### Claude Code and the CLI — the bearer token
|
||||
|
||||
Both can send a header, so they keep using `MCP_AUTH_TOKEN` on the plain `/mcp`
|
||||
and `/api`:
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http --scope user schulcloud https://mcp.example.org/mcp \
|
||||
--header "Authorization: Bearer <MCP_AUTH_TOKEN>"
|
||||
schulcloud login --server https://mcp.example.org --token <MCP_AUTH_TOKEN>
|
||||
```
|
||||
|
||||
## The WebUntis key
|
||||
|
||||
The timetable comes from WebUntis, which needs four values in `.env` —
|
||||
`UNTIS_SERVER`, `UNTIS_SCHOOL`, `UNTIS_USER`, `UNTIS_SECRET` — all from WebUntis
|
||||
→ Profil → Freigaben → Untis Mobile → QR-Code. With them the server offers the
|
||||
`untis_*` tools and the `tagesvorbereitung` prompt; without them it does not,
|
||||
and nothing else changes.
|
||||
|
||||
Operationally it is the easy credential: no password, it survives the container
|
||||
being off, and it expires only when you generate a new key. Two consequences
|
||||
for a deployment:
|
||||
|
||||
- **Recreate the container after changing it** (`docker compose up -d
|
||||
--force-recreate schulcloud-mcp`) — `env_file` is read at creation, and unlike
|
||||
the Schulcloud token there is no live swap, because there is no monthly chore
|
||||
to spare anyone.
|
||||
- **Keep the host's clock synchronised.** Requests are signed with a
|
||||
time-based code; a drifting clock is refused with "invalid client time", which
|
||||
the tools report in those words.
|
||||
|
||||
## The notes app
|
||||
|
||||
Rolling this onto a server that is already running has its own runbook:
|
||||
[DEPLOY-NOTES.md](DEPLOY-NOTES.md).
|
||||
|
||||
Set `WEB_PASSWORD` in `.env` and the server offers `/app`: the notes editor and
|
||||
a settings page. Unset, it is not served at all, and nothing else changes.
|
||||
|
||||
```
|
||||
https://mcp.example.org/app/
|
||||
```
|
||||
|
||||
Three things worth knowing for a deployment:
|
||||
|
||||
- **Recreate the container after changing the password** (`docker compose up -d
|
||||
--force-recreate schulcloud-mcp`) — `env_file` is read at creation. Changing
|
||||
it also logs out every session, by design: the session signing key is derived
|
||||
from it.
|
||||
- **Notes need somewhere to live.** `docker-compose.yml` sets
|
||||
`NOTES_DIR=/data/notes` with a volume of its own. To write the notes from a
|
||||
phone through a sync tool as well as through the app, bind-mount a real
|
||||
directory there instead — see [NOTES.md](NOTES.md).
|
||||
- **The app is a way to replace the Schulcloud token**, which is the next
|
||||
section, and the more comfortable one when the expiry catches you away from a
|
||||
terminal.
|
||||
|
||||
`/token` still exists and still works. It is the fallback for a deployment with
|
||||
no `WEB_PASSWORD`, and it is unchanged.
|
||||
|
||||
## Replacing the Schulcloud token
|
||||
|
||||
The token lasts 30 days at most and can only come from a browser login (see
|
||||
docs/AUTH.md), so this is the monthly chore — but it no longer needs a restart
|
||||
or an `.env` edit:
|
||||
|
||||
1. Log in to Schulcloud in a **private window** and copy the `jwt` cookie's
|
||||
value (DevTools → Application → Cookies).
|
||||
2. Either run `schulcloud token set` and paste it, open the app's
|
||||
**Einstellungen** tab, or open `https://mcp.example.org/token` and paste it
|
||||
together with `MCP_AUTH_TOKEN`.
|
||||
3. **Close the private window.**
|
||||
|
||||
The server checks the token with Schulcloud first — right account, not
|
||||
expired, not logged out — then swaps it in, restarts the keepalive and saves it
|
||||
to the `state` volume (`STATE_DIR=/data/state`), so a restart keeps it. At
|
||||
startup the newer of the saved token and `TSC_JWT_COOKIE` wins, unless the two
|
||||
are for different accounts: changing `TSC_JWT_COOKIE` is how you switch
|
||||
accounts. `schulcloud token` shows the days left; from a week before expiry the
|
||||
log, `whoami` and the CLI warn about it.
|
||||
|
||||
## Running it locally instead
|
||||
|
||||
@@ -162,15 +288,44 @@ use stdio:
|
||||
|
||||
`MCP_AUTH_TOKEN` is irrelevant in stdio mode — there is no network listener.
|
||||
|
||||
## Publishing an image
|
||||
|
||||
The Pi never builds: `deploy/docker-compose.pi.yml` runs
|
||||
`registry.mc02.dev/schulcloud-mcp` and removes the build section it would
|
||||
otherwise inherit. Images are published from a development machine:
|
||||
|
||||
```bash
|
||||
npm run publish-image
|
||||
docker buildx imagetools inspect registry.mc02.dev/schulcloud-mcp:latest # lists linux/amd64 and linux/arm64
|
||||
```
|
||||
|
||||
`scripts/publish-image.sh` refuses a working tree with uncommitted changes, so
|
||||
a tag always names exactly the code of one commit. It builds for `linux/amd64`
|
||||
and `linux/arm64` and pushes two tags: `latest`, and the short commit id. It
|
||||
also labels the image with the full commit and the repository.
|
||||
|
||||
Two things the build machine needs:
|
||||
|
||||
- **arm64 emulation on an x86 machine.** The script checks for it and prints
|
||||
the command that registers QEMU —
|
||||
`docker run --privileged --rm tonistiigi/binfmt --install arm64` — which lasts
|
||||
until the next reboot.
|
||||
- **A multi-platform push.** Docker's containerd image store supports it
|
||||
directly. Without that store, create a builder first with
|
||||
`docker buildx create --use`.
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
cd /opt/schulcloud-mcp && git pull
|
||||
docker compose up -d --build
|
||||
docker compose exec schulcloud-mcp node -e "1" # sanity
|
||||
npm run probe # re-verify the API assumptions
|
||||
npm run publish-image # on a development machine
|
||||
cd /opt/schulcloud-mcp && git pull # on the Pi: compose files and docs
|
||||
docker compose pull && docker compose up -d
|
||||
npm run probe # on a development machine: re-verify the API assumptions
|
||||
```
|
||||
|
||||
A `.env` that pins `SCHULCLOUD_MCP_TAG` needs the new commit id before the pull.
|
||||
Setting the previous one rolls back.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- **Restart policy** is `unless-stopped`; the container comes back after a
|
||||
@@ -179,9 +334,10 @@ npm run probe # re-verify the API assumptions
|
||||
invalidates them; Claude re-initializes transparently.
|
||||
- **Logs** are capped at 3 × 10 MB. The Authorization header is never logged.
|
||||
- **The container is read-only** with `cap_drop: ALL` and `no-new-privileges`,
|
||||
running as the unprivileged `node` user. The one writable path is the mirror
|
||||
volume at `/data/mirror`, which holds downloaded file bytes; everything else
|
||||
stays read-only.
|
||||
running as the unprivileged `node` user. The writable paths are the mirror
|
||||
volume at `/data/mirror`, which holds downloaded file bytes, and the state
|
||||
volume at `/data/state`, which holds a replaced Schulcloud token (mode 0600).
|
||||
Both belong in no backup that leaves the Pi unencrypted.
|
||||
- **The mirror grows.** It holds a copy of every course file under
|
||||
`MIRROR_MAX_BYTES` (64 MiB default). Larger files — videos, mostly — are
|
||||
indexed as metadata and proxied live on request instead. Budget a few GB.
|
||||
@@ -196,6 +352,8 @@ npm run probe # re-verify the API assumptions
|
||||
the session and its auto-logout will revoke it ~2h after login. Copy the
|
||||
cookie in a private window and close it — see docs/AUTH.md.
|
||||
- **Downtime longer than two hours lapses the session** and restarting does not
|
||||
recover it: a long power cut means pasting a fresh `TSC_JWT_COOKIE`.
|
||||
- **Monthly chore**: refresh `TSC_JWT_COOKIE` before its 30-day hard expiry.
|
||||
`npm run probe` reports both clocks.
|
||||
recover it: a long power cut means handing the server a fresh token
|
||||
(`schulcloud token set`), no restart needed.
|
||||
- **Monthly chore**: replace the token before its 30-day hard expiry — see
|
||||
*Replacing the Schulcloud token*. `schulcloud token` and `npm run probe`
|
||||
report the clocks.
|
||||
|
||||
@@ -14,8 +14,22 @@ curl -s http://127.0.0.1:8080/healthz # {"status":"ok","sessions":0,"index":
|
||||
|
||||
`docker-compose.override.yml` is merged automatically and is **local-only**: it
|
||||
publishes the server on `127.0.0.1:8080` and Postgres on `127.0.0.1:55432`, and
|
||||
switches crawling to on-demand. On the Pi neither port is published — Caddy
|
||||
reaches the container over the Docker network.
|
||||
switches crawling to on-demand. Two `.env` settings adjust it: `MCP_HOST_PORT`
|
||||
moves the published port when 8080 is taken, and `CRAWL_INTERVAL_MS` restores a
|
||||
crawl timer — worth doing against a real account, because `what_changed` can
|
||||
only report what happened between crawls.
|
||||
|
||||
A fresh Schulcloud token goes in with `schulcloud token set`, or on the page at
|
||||
`http://127.0.0.1:8080/token` — no restart; see docs/CLI.md. Editing
|
||||
`TSC_JWT_COOKIE` in `.env` instead needs the container **recreated**, because
|
||||
`env_file` is read when the container is created, not on restart:
|
||||
|
||||
```bash
|
||||
docker compose up -d --force-recreate schulcloud-mcp
|
||||
```
|
||||
|
||||
On the Pi neither port is published — Caddy reaches the container over the
|
||||
Docker network.
|
||||
|
||||
Loopback binding is deliberate. The bearer token is the only thing in front of
|
||||
your account's data, so it should not be listening on your LAN while you test.
|
||||
@@ -93,9 +107,9 @@ node dist/bin/cli.js sync
|
||||
## Run the test suites
|
||||
|
||||
```bash
|
||||
npm test # 58 offline tests
|
||||
npm test # 239 offline tests
|
||||
npm run smoke # end-to-end against the live instance, live-only mode
|
||||
DATABASE_URL=… npm run smoke # end-to-end with the index (34 checks)
|
||||
DATABASE_URL=… npm run smoke # end-to-end with the index (93 checks with a WebUntis key)
|
||||
```
|
||||
|
||||
Store tests need a database and skip without one:
|
||||
|
||||
297
docs/NOTES.md
Normal file
297
docs/NOTES.md
Normal file
@@ -0,0 +1,297 @@
|
||||
# Your own lesson notes
|
||||
|
||||
Schulcloud holds the material and WebUntis holds the schedule. Neither holds
|
||||
what was actually said in the room — which teacher stressed what, the example
|
||||
that finally made it click, the aside that turns up in the test. That is in
|
||||
whatever you write down during the lesson, and until now it lived somewhere no
|
||||
agent could read.
|
||||
|
||||
This is the third source: a directory of Markdown files the server reads,
|
||||
indexes and searches alongside everything else, and a small web app to write
|
||||
them in.
|
||||
|
||||
## One note per school day
|
||||
|
||||
```
|
||||
NOTES_DIR/
|
||||
2026/
|
||||
2026-09-18.md ← Freitag, one heading per lesson
|
||||
2026-09-21.md
|
||||
Deutsch/
|
||||
2026-09-15 Erörterung.md ← a single-subject note, e.g. from the import
|
||||
```
|
||||
|
||||
A day note looks like this, and the shape is load-bearing:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Freitag, 18.09.2026
|
||||
date: 2026-09-18
|
||||
source: notes-page
|
||||
---
|
||||
|
||||
## 1. Deutsch — 08:00–08:45 · MEI · R 204
|
||||
|
||||
Dreischritt: These, Argument mit Beleg, Fazit.
|
||||
|
||||
### Aufbau
|
||||
|
||||
- Gegenargument nicht vergessen — kam letztes Jahr in der Arbeit dran
|
||||
|
||||
## 2. LF07 — 08:50–09:35 · Sb · R 108
|
||||
|
||||
| Präfix | Nutzbare Adressen |
|
||||
|---|---|
|
||||
| /24 | 254 |
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
## The app
|
||||
|
||||
`/app` is a small web app: a login, the day's notes, and a settings page. It is
|
||||
served only when `WEB_PASSWORD` is set.
|
||||
|
||||
```
|
||||
https://mcp.example.org/app/
|
||||
```
|
||||
|
||||
On a phone it is worth adding to the home screen — it has a manifest and opens
|
||||
standalone, which is the difference between "a page I have to find" and "the
|
||||
thing I open in a free period".
|
||||
|
||||
**Notizen** is one screen: the day, `‹ ›` to move between days, and the editor.
|
||||
|
||||
- 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.
|
||||
|
||||
### Writing, without typing Markdown
|
||||
|
||||
The editor shows the note **formatted** — headings as headings, bold as bold,
|
||||
tables as tables — and the toolbar above it writes the Markdown. Nobody types
|
||||
`##` or `**` during a lesson.
|
||||
|
||||
| Button | What it writes |
|
||||
| --- | --- |
|
||||
| `H2` | a lesson heading — the one that makes the lesson separately searchable |
|
||||
| `H3` | a subheading inside a lesson |
|
||||
| `F` `K` `S` | **fett**, _kursiv_, ~~durchgestrichen~~ (`Strg`/`Cmd` + B, I) |
|
||||
| `<>` | inline code (`Strg`/`Cmd` + E) |
|
||||
| `• —` `1. —` | bullet and numbered lists; nest them with Tab |
|
||||
| `☐` | a box to tick off |
|
||||
| `❝` | a quote — the teacher's exact wording |
|
||||
| `🔗` `▦` | a link (`Strg`/`Cmd` + K) and a table |
|
||||
| `MD` | the Markdown itself |
|
||||
|
||||
`Enter` starts a new paragraph, `Shift+Enter` a new line in the same one. A
|
||||
paste from a web page or a PDF keeps its structure and loses its fonts, colours
|
||||
and anything else that is not in the list above — pasted HTML is converted to
|
||||
Markdown before it reaches the page, which is what keeps a copied page from
|
||||
bringing its script along.
|
||||
|
||||
**The file is still Markdown.** `MD` shows it and lets you edit it directly,
|
||||
which is the way to write something the toolbar has no button for. There is no
|
||||
underline, because Markdown cannot store one — `F` or `K` instead.
|
||||
|
||||
Opening a note may tidy it once: `*so*` becomes `_so_`, a table typed unevenly
|
||||
lines up. Nothing is rewritten until you actually change something, and a note
|
||||
whose formatting the view cannot hold unchanged **opens as Markdown** and says
|
||||
so rather than being quietly reduced. `test/app-markdown.test.ts` is what holds
|
||||
that promise up: every construct in this document goes in and comes back out
|
||||
unchanged.
|
||||
|
||||
**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
|
||||
# the command line, text piped in
|
||||
pbpaste | schulcloud note add --title "Subnetting" --subject LF07
|
||||
|
||||
# an editor, or anything else that writes files
|
||||
$EDITOR "$NOTES_DIR/2026/2026-09-18.md"
|
||||
|
||||
# Claude, during or after the lesson
|
||||
# "halt fest: Gegenargument nicht vergessen, kam letztes Jahr dran"
|
||||
# → add_note, subject Deutsch, today's date
|
||||
|
||||
# a folder you already sync — see below
|
||||
```
|
||||
|
||||
`add_note` and `schulcloud note add` take `append`, which adds to the note
|
||||
already written **for that subject and that day** rather than starting a second
|
||||
one — so "note this down too" mid-lesson lands in the note that is already open,
|
||||
whatever its title.
|
||||
|
||||
## What may write, and where
|
||||
|
||||
**The notes directory is the only thing this server writes to.** Not Schulcloud,
|
||||
not WebUntis — those stay read-only, strictly, and that has not changed.
|
||||
|
||||
Every path component goes through `safeComponent` and the result through
|
||||
`resolveWithin`, the same two functions that stop a hostile Schulcloud filename
|
||||
escaping the file mirror, so a note titled `../../.ssh/authorized_keys` becomes
|
||||
a filename. `NOTES_READONLY=1` refuses writes entirely — right when the notes
|
||||
are synced in from somewhere else and should have exactly one writer. The app
|
||||
then still reads them, and says so rather than failing on save.
|
||||
|
||||
## Migrating out of Apple Notes
|
||||
|
||||
Notes.app has no export. Its database is a Core Data store whose bodies are
|
||||
compressed protobuf and whose iCloud copy is encrypted, so scripting the app is
|
||||
not the clumsy route to your notes — it is the only one.
|
||||
|
||||
**On the Mac**, from a checkout of this repo:
|
||||
|
||||
```bash
|
||||
osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson
|
||||
```
|
||||
|
||||
The first run raises a macOS permission dialog ("Terminal wants access to
|
||||
Notes"); without it every note comes back empty. `--folder Deutsch` exports one
|
||||
Notes folder.
|
||||
|
||||
Then look at what it would do, and do it:
|
||||
|
||||
```bash
|
||||
schulcloud note import notes.ndjson --dry-run
|
||||
schulcloud note import notes.ndjson # into the server
|
||||
schulcloud note import notes.ndjson --out ~/Notizen # or to a local folder first
|
||||
```
|
||||
|
||||
```
|
||||
would import: 2026-09-15 · Deutsch · Erörterung
|
||||
Would import 84 of 91 note(s), skipped 5 with no text, 2 unreadable in Notes.
|
||||
```
|
||||
|
||||
What it does with each note:
|
||||
|
||||
- **The Notes folder becomes the subject** — its last segment, so `Schule/Deutsch`
|
||||
is `Deutsch`. Notes' own default folders (`Notizen`, `Recently Deleted`) are
|
||||
ignored rather than becoming a subject. `--subject LF07` overrides all of it.
|
||||
- **The creation date becomes the note's date**, because that is the day of the
|
||||
lesson. The modification date is whenever you last tidied it up, which is not
|
||||
a school day at all.
|
||||
- **The HTML becomes Markdown** — headings, lists, checklists, bold, italics and
|
||||
links survive; anything else keeps its words and loses its tag.
|
||||
- **Attachments do not come across.** A note that was a photo of the board
|
||||
imports as a line saying an attachment was there. Better than importing empty:
|
||||
you can see which notes still need the picture.
|
||||
- **Locked notes cannot be read at all** and are listed by name at the end.
|
||||
Unlock them in Notes and export again.
|
||||
|
||||
Imported notes arrive as single-subject notes, not day notes, which is the
|
||||
shape they were written in. Both kinds coexist.
|
||||
|
||||
Re-running the import creates second copies rather than overwriting, since the
|
||||
importer cannot tell an edited note from a new one with the same title. Import
|
||||
once, then keep writing in the new place.
|
||||
|
||||
## 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.
|
||||
|
||||
## Deploying it
|
||||
|
||||
Adding this to a server that is already running — the password, where the notes
|
||||
live, backups and rollback — is [DEPLOY-NOTES.md](DEPLOY-NOTES.md).
|
||||
|
||||
## Keeping them somewhere you already sync
|
||||
|
||||
The notes are files, so any sync tool will do and the server does not need to
|
||||
know which. Bind-mount the folder instead of using the volume:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml, under schulcloud-mcp:
|
||||
volumes:
|
||||
- /home/pi/Notizen:/data/notes
|
||||
```
|
||||
|
||||
Then point Syncthing, Nextcloud, an Obsidian vault or `git` at
|
||||
`/home/pi/Notizen`. New files are picked up by the next crawl; nothing has to be
|
||||
told about them, and the app edits the same files.
|
||||
|
||||
Frontmatter is a small YAML subset — scalars and lists, in either the inline
|
||||
`[a, b]` or the indented `- item` form editors write — so an Obsidian vault
|
||||
round-trips. Unknown keys are kept and ignored.
|
||||
|
||||
What the server reads out of a note:
|
||||
|
||||
| Field | Falls back to |
|
||||
|---|---|
|
||||
| `title` | the first `# heading`, else the filename without its date |
|
||||
| `date` | a `YYYY-MM-DD` the **filename** starts with, else nothing |
|
||||
| `subject` | the first folder name — except a year, which is a filing scheme |
|
||||
| `tags` | none |
|
||||
| `courseId` | none — set it to tie a note to a Schulcloud course in `search` |
|
||||
|
||||
`date` also accepts `15.09.2026`, and `fach:` works as a German spelling of
|
||||
`subject:`.
|
||||
|
||||
**The file's modification time is never used as the date.** An import writes
|
||||
every note today; dating a year of lessons "today" would make the whole store
|
||||
useless for revision, which is most of what it is for.
|
||||
|
||||
## What the crawl does with them
|
||||
|
||||
A **full** crawl reads the directory and indexes every note — one entry per
|
||||
lesson for a day note, one for the whole note otherwise, under the kind `note`.
|
||||
A **per-course** refresh leaves them alone, and the store carries the previous
|
||||
generation's rows forward, so a per-course crawl never looks like the notes were
|
||||
deleted.
|
||||
|
||||
Notes are diffed by content, not by modification time, so a sync tool that
|
||||
rewrites a file byte-for-byte does not show up in `what_changed` as an edit.
|
||||
487
docs/PI.md
Normal file
487
docs/PI.md
Normal file
@@ -0,0 +1,487 @@
|
||||
# Setting up on the Raspberry Pi
|
||||
|
||||
A step-by-step guide from a Pi with Docker to a working claude.ai connector.
|
||||
[DEPLOYMENT.md](DEPLOYMENT.md) explains *why* each piece is there; this page is
|
||||
the order to do it in.
|
||||
|
||||
The Pi never builds the server. It pulls the image published to
|
||||
`registry.mc02.dev`, built for arm64 and amd64 by `npm run publish-image` on a
|
||||
development machine ([DEPLOYMENT.md](DEPLOYMENT.md#publishing-an-image)).
|
||||
|
||||
```
|
||||
claude.ai ─┐ ┌──────────── Pi (home, always on) ─────────────┐
|
||||
Claude Code├─HTTPS─▶ VPS ─TCP─▶ │ Caddy ─▶ schulcloud-mcp ─▶ schulcloud-thueringen.de
|
||||
CLI ───────┘ (public IP, │ (TLS) └─ postgres (private network) │
|
||||
your domain) └────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## What you need
|
||||
|
||||
- **The Pi**, always on. More than two hours offline ends the Schulcloud
|
||||
session, and only a new token from a browser revives it. A Pi 5 on 64-bit Raspberry Pi
|
||||
OS is plenty; put Docker's data on an SSD rather than the SD card if you can,
|
||||
since Postgres and the file mirror (about 1.3 GB on the current account) write
|
||||
to it.
|
||||
- **Docker Engine with the Compose plugin**, Compose 2.24 or later, on the Pi.
|
||||
- **A login for `registry.mc02.dev`**, where the image is published.
|
||||
- **Caddy running in a container on the Pi**, serving ports 80 and 443. This
|
||||
server joins its Docker network; it opens no port of its own.
|
||||
- **The VPS**, with a public IPv4 address, forwarding TCP ports 80 and 443 to the
|
||||
Pi (step 7).
|
||||
- **A hostname** in your domain — `mcp.example.org` below — whose `A` record
|
||||
points at the VPS.
|
||||
- **Your Schulcloud login**, for the first token, and a machine with the
|
||||
`schulcloud` CLI (`npm link` in a checkout; see [CLI.md](CLI.md)).
|
||||
|
||||
## 1. Prepare the Pi
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt full-upgrade -y
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker "$USER" # then log out and back in
|
||||
docker compose version # v2.24 or later
|
||||
timedatectl # "System clock synchronized: yes"
|
||||
docker login registry.mc02.dev
|
||||
```
|
||||
|
||||
The clock matters: token expiry and TLS certificates are both judged by it. The
|
||||
login is needed to pull, not only to push; Docker keeps it in
|
||||
`~/.docker/config.json`.
|
||||
|
||||
## 2. Get the compose files
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/schulcloud-mcp && sudo chown "$USER": /opt/schulcloud-mcp
|
||||
git clone https://git.mc02.dev/fabi/Schulcloud-MCP.git /opt/schulcloud-mcp
|
||||
cd /opt/schulcloud-mcp
|
||||
```
|
||||
|
||||
Every command below runs in `/opt/schulcloud-mcp`. The checkout provides the
|
||||
compose files, `.env.example` and the Caddy snippet; its source code is not built
|
||||
here.
|
||||
|
||||
## 3. Configure
|
||||
|
||||
Find the Docker network your Caddy container is on:
|
||||
|
||||
```bash
|
||||
docker ps --format '{{.Names}}' | grep -i caddy
|
||||
docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' <caddy-container>
|
||||
```
|
||||
|
||||
Create `.env`, readable by you alone, and add the Pi's settings with freshly
|
||||
generated secrets — replace `<network>` with the name you just found:
|
||||
|
||||
```bash
|
||||
cp .env.example .env && chmod 600 .env
|
||||
sed -i '/^MCP_AUTH_TOKEN=$/d; /^DATABASE_URL=/d' .env
|
||||
cat >> .env <<EOF
|
||||
|
||||
# --- the Pi ---
|
||||
COMPOSE_FILE=docker-compose.yml:deploy/docker-compose.pi.yml
|
||||
CADDY_NETWORK=<network>
|
||||
POSTGRES_PASSWORD=$(openssl rand -hex 24)
|
||||
MCP_AUTH_TOKEN=$(openssl rand -hex 32)
|
||||
MCP_CONNECTOR_TOKEN=$(openssl rand -hex 32)
|
||||
INDEX_PERSONAL_FILES=true
|
||||
# SCHULCLOUD_MCP_TAG=latest
|
||||
EOF
|
||||
```
|
||||
|
||||
To write your own lesson notes — and to replace the Schulcloud token from a
|
||||
phone rather than a terminal — set a password for the app at `/app`. Unlike the
|
||||
tokens above it is typed by a person, so it is a passphrase: **at least 12
|
||||
characters, three or four words**. See [NOTES.md](NOTES.md).
|
||||
|
||||
```bash
|
||||
cat >> .env <<'EOF'
|
||||
WEB_PASSWORD=change-this-to-three-or-four-words
|
||||
EOF
|
||||
```
|
||||
|
||||
If your school publishes its timetable in **WebUntis**, add those four values
|
||||
too — the timetable, its cancellations and substitutions are not in Schulcloud
|
||||
at all. They are in WebUntis under Profil → Freigaben → Untis Mobile → QR-Code:
|
||||
|
||||
```bash
|
||||
cat >> .env <<'EOF'
|
||||
UNTIS_SERVER=yourschool.webuntis.com
|
||||
UNTIS_SCHOOL=yourschool
|
||||
UNTIS_USER=your.username
|
||||
UNTIS_SECRET=THEKEYFROMTHEQRDIALOG
|
||||
EOF
|
||||
```
|
||||
|
||||
What those lines do:
|
||||
|
||||
| Setting | |
|
||||
|---|---|
|
||||
| `COMPOSE_FILE` | Makes every `docker compose` command here use [`deploy/docker-compose.pi.yml`](../deploy/docker-compose.pi.yml), which runs the registry image — removing the build section, so nothing can be built here — joins Caddy's network and wires up the database. It also keeps `docker-compose.override.yml` out — that one is for local development and would publish ports and switch the crawl timer off. |
|
||||
| `CADDY_NETWORK` | The network Caddy reaches this server on, by the name `schulcloud-mcp`. |
|
||||
| `POSTGRES_PASSWORD` | The bundled Postgres, which sits on a private network with this server only. Hex, so it needs no escaping inside the connection URL. |
|
||||
| `MCP_AUTH_TOKEN` | What Claude Code, the CLI and the `/token` page present. |
|
||||
| `MCP_CONNECTOR_TOKEN` | What claude.ai sends as a request header. It opens `/mcp` only, never `/api`, because claude.ai stores it. |
|
||||
| `INDEX_PERSONAL_FILES` | Also indexes your own files and handed-in work, including teachers' feedback. Optional. |
|
||||
| `UNTIS_*` | WebUntis, where the school keeps the timetable. All four or none; the key needs no password and does not expire. See [AUTH.md](AUTH.md). Optional. |
|
||||
| `WEB_PASSWORD` | The app at `/app`: the notes editor and a settings page for the Schulcloud token. Unset means no app is served at all. Hashed at startup and never logged; changing it logs out every session. Optional. |
|
||||
| `SCHULCLOUD_MCP_TAG` | Which published image to run. Unset means `latest`; a commit id such as `bac9130` pins it, so updates happen only when you change it. Optional. |
|
||||
|
||||
**Copy `MCP_AUTH_TOKEN`, `MCP_CONNECTOR_TOKEN` and `WEB_PASSWORD` into your
|
||||
password manager now** — you need the first two again in step 9, and none of
|
||||
them is ever printed by the server:
|
||||
|
||||
```bash
|
||||
grep -E '^(MCP_AUTH_TOKEN|MCP_CONNECTOR_TOKEN|WEB_PASSWORD)=' .env
|
||||
```
|
||||
|
||||
## 4. The first Schulcloud token
|
||||
|
||||
1. Open a **private window** and log in to Schulcloud.
|
||||
2. DevTools (F12) → *Application* (Firefox: *Storage*) → *Cookies* → the cookie
|
||||
named `jwt` → copy its value.
|
||||
3. Put it into `.env` as `TSC_JWT_COOKIE=<value>`.
|
||||
4. **Close the private window.** Left open, it logs the token out about two hours
|
||||
after login ([AUTH.md](AUTH.md) has the whole story).
|
||||
|
||||
This is the only time the token goes into `.env`. Later ones go in without a
|
||||
restart (step 11).
|
||||
|
||||
## 5. Start the server
|
||||
|
||||
```bash
|
||||
docker compose pull # the published image, and Postgres
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
docker compose logs -f schulcloud-mcp
|
||||
```
|
||||
|
||||
Nothing is built on the Pi: `pull` fetches the arm64 variant of the image, and
|
||||
with the Pi file in place no `docker compose` command can fall back to a build.
|
||||
|
||||
Expect, within a few seconds:
|
||||
|
||||
```
|
||||
[schulcloud-mcp] listening on 0.0.0.0:8080 — instance https://schulcloud-thueringen.de, auth enabled (plus connector token), token from environment, 29 day(s) left, keepalive every 30min, index every 6h
|
||||
[schulcloud-mcp] keepalive: session extended, 7200s (120 min) of budget left
|
||||
```
|
||||
|
||||
- `auth DISABLED` means `MCP_AUTH_TOKEN` is empty. Stop and fix it.
|
||||
- `keepalive: token rejected (401)` means the token is already dead: get a fresh
|
||||
one (step 4) and hand it over with `schulcloud token set` once step 9 is done.
|
||||
|
||||
The index is empty, so the first full crawl starts on its own. It downloads every
|
||||
course file once — about 15 minutes and 1.3 GB on the current account — and the
|
||||
server answers normally meanwhile.
|
||||
|
||||
`docker compose ps` must show **no published ports** for either container.
|
||||
|
||||
## 6. Add the site to Caddy
|
||||
|
||||
Append [`deploy/Caddyfile.snippet`](../deploy/Caddyfile.snippet) to the Pi's
|
||||
Caddyfile, with your hostname in place of `mcp.example.org`, then validate and
|
||||
reload:
|
||||
|
||||
```bash
|
||||
docker exec <caddy-container> caddy validate --config /etc/caddy/Caddyfile
|
||||
docker exec <caddy-container> caddy reload --config /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
Keep the snippet's three easily-missed settings:
|
||||
|
||||
- `flush_interval -1`, or claude.ai's connection hangs without an error.
|
||||
- The long timeouts, or a slow `search` is cut off.
|
||||
- The `format filter` in `log`, which keeps a secret path out of the access log if
|
||||
you ever use one (step 9).
|
||||
|
||||
Caddy gets its certificate once DNS and the forwarding work (step 7). Watch for
|
||||
it with `docker logs -f <caddy-container> | grep -i certificate`.
|
||||
|
||||
## 7. DNS and the VPS
|
||||
|
||||
**DNS:** an `A` record for `mcp.example.org` pointing at the VPS's public IPv4.
|
||||
Publish no `AAAA` record unless the VPS forwards IPv6 as well.
|
||||
|
||||
**The forwarding must pass TCP through, untouched.** TLS has to end at Caddy on
|
||||
the Pi. A VPS that terminates TLS itself, or proxies HTTP, sees every request —
|
||||
the claude.ai token in its header included — and may log it. Both ports are
|
||||
needed: 80 for the certificate challenge, 443 for everything else.
|
||||
|
||||
If the VPS already forwards to the Pi, check how. On the VPS:
|
||||
|
||||
```bash
|
||||
sudo ss -ltnp '( sport = :443 )'
|
||||
```
|
||||
|
||||
No listening process is the good answer: the kernel forwards the packets, as in
|
||||
the example below. An nginx `stream` block or HAProxy in `mode tcp` is also fine.
|
||||
An nginx `http` server, a Caddy, or HAProxy in `mode http` on the VPS is not.
|
||||
|
||||
### Example: WireGuard and nftables
|
||||
|
||||
Skip this if your forwarding already passes TCP through. Otherwise, a minimal
|
||||
tunnel with the VPS as `10.8.0.1` and the Pi as `10.8.0.2`. Create keys on each
|
||||
machine with `umask 077; wg genkey | tee private.key | wg pubkey > public.key`.
|
||||
|
||||
On the VPS, `/etc/wireguard/wg0.conf`:
|
||||
|
||||
```ini
|
||||
[Interface]
|
||||
Address = 10.8.0.1/24
|
||||
ListenPort = 51820
|
||||
PrivateKey = <vps private key>
|
||||
|
||||
[Peer]
|
||||
PublicKey = <pi public key>
|
||||
AllowedIPs = 10.8.0.2/32
|
||||
```
|
||||
|
||||
On the Pi, `/etc/wireguard/wg0.conf`:
|
||||
|
||||
```ini
|
||||
[Interface]
|
||||
Address = 10.8.0.2/24
|
||||
PrivateKey = <pi private key>
|
||||
|
||||
[Peer]
|
||||
PublicKey = <vps public key>
|
||||
Endpoint = <vps public ip>:51820
|
||||
AllowedIPs = 10.8.0.1/32
|
||||
PersistentKeepalive = 25
|
||||
```
|
||||
|
||||
Bring the tunnel up on both (`sudo apt install wireguard` first), and keep it up
|
||||
across reboots:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable --now wg-quick@wg0
|
||||
ping -c 3 10.8.0.1 # from the Pi
|
||||
```
|
||||
|
||||
On the VPS, turn on forwarding and send ports 80 and 443 into the tunnel.
|
||||
Replace `eth0` with the VPS's public interface (`ip route get 1.1.1.1` names it):
|
||||
|
||||
```bash
|
||||
echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-forward.conf
|
||||
sudo sysctl --system
|
||||
```
|
||||
|
||||
```nft
|
||||
# /etc/nftables.conf on the VPS (merge into what is there)
|
||||
table ip schulcloud_forward {
|
||||
chain prerouting {
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
iifname "eth0" tcp dport { 80, 443 } dnat to 10.8.0.2
|
||||
}
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
oifname "wg0" ip daddr 10.8.0.2 tcp dport { 80, 443 } masquerade
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo nft -c -f /etc/nftables.conf && sudo systemctl enable --now nftables
|
||||
```
|
||||
|
||||
If the VPS's `forward` chain has `policy drop`, also accept `ct state
|
||||
established,related` and new TCP 80/443 from `eth0` to `wg0`. Its firewall
|
||||
needs 80/tcp, 443/tcp and 51820/udp open, plus SSH. With the masquerade, Caddy's
|
||||
logs show the VPS's tunnel address as the client; nothing here relies on client
|
||||
addresses.
|
||||
|
||||
Check from the VPS that the Pi's Caddy answers through the tunnel:
|
||||
|
||||
```bash
|
||||
curl -sI http://10.8.0.2/ -H 'Host: mcp.example.org' | head -1 # a redirect to https
|
||||
```
|
||||
|
||||
## 8. Check it from outside
|
||||
|
||||
From any machine that is not the Pi:
|
||||
|
||||
```bash
|
||||
curl -s https://mcp.example.org/healthz
|
||||
# {"status":"ok","sessions":0,"index":"on"}
|
||||
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.org/mcp \
|
||||
-H 'content-type: application/json' -d '{}'
|
||||
# 401 ← the bearer check
|
||||
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.org/$(openssl rand -hex 32)/mcp \
|
||||
-H 'content-type: application/json' -d '{}'
|
||||
# 404 ← a wrong path secret, answered like any unknown path
|
||||
```
|
||||
|
||||
Then confirm that TLS really ends on the Pi: the certificate the world sees is
|
||||
the one Caddy there obtained.
|
||||
|
||||
```bash
|
||||
echo | openssl s_client -connect mcp.example.org:443 -servername mcp.example.org 2>/dev/null \
|
||||
| openssl x509 -noout -issuer -enddate # from outside
|
||||
docker logs <caddy-container> 2>&1 | grep -i 'certificate obtained' | tail -1 # on the Pi
|
||||
```
|
||||
|
||||
## 9. Connect Claude and the CLI
|
||||
|
||||
**CLI**, on your laptop:
|
||||
|
||||
```bash
|
||||
schulcloud login --server https://mcp.example.org --token <MCP_AUTH_TOKEN>
|
||||
schulcloud token # expires … (29 day(s) left); session alive, 120 min budget; from TSC_JWT_COOKIE
|
||||
schulcloud status # after the first crawl: generation 1 — crawled … min ago
|
||||
```
|
||||
|
||||
**Claude Code**, replacing the registration that points at the laptop:
|
||||
|
||||
```bash
|
||||
claude mcp remove --scope user schulcloud
|
||||
claude mcp add --transport http --scope user schulcloud https://mcp.example.org/mcp \
|
||||
--header "Authorization: Bearer <MCP_AUTH_TOKEN>"
|
||||
claude mcp list # schulcloud: https://mcp.example.org/mcp (HTTP) - ✔ Connected
|
||||
```
|
||||
|
||||
**claude.ai:**
|
||||
|
||||
1. *Customize → Connectors → Add custom connector*. Name: `Schulcloud`. URL:
|
||||
`https://mcp.example.org/mcp`.
|
||||
2. The next step shows **No sign-in** as detected — keep it — and a **Request
|
||||
headers** section. Add one: name `authorization`, value
|
||||
`Bearer <MCP_CONNECTOR_TOKEN>`, with the space. Add the connector.
|
||||
3. In a chat: **+ → Connectors** → switch *Schulcloud* on, and ask *"Welche Kurse
|
||||
habe ich?"*
|
||||
|
||||
claude.ai stores the header and never shows it again; to change it, remove the
|
||||
connector and add it again.
|
||||
|
||||
*Without request headers* — say, for a client that cannot send them — use a
|
||||
secret path instead:
|
||||
|
||||
```bash
|
||||
echo "MCP_PATH_SECRET=$(openssl rand -hex 32)" >> .env
|
||||
docker compose up -d --force-recreate schulcloud-mcp
|
||||
```
|
||||
|
||||
The URL is then `https://mcp.example.org/<MCP_PATH_SECRET>/mcp`, with no header.
|
||||
It is the credential itself, so keep it out of screenshots and notes;
|
||||
[DEPLOYMENT.md](DEPLOYMENT.md#without-request-headers--a-secret-path) says what
|
||||
that trades away.
|
||||
|
||||
**Retire the laptop's container** once the Pi answers — in the laptop checkout,
|
||||
`docker compose down` keeps its index and mirror volumes. Its session is separate
|
||||
and simply lapses.
|
||||
|
||||
## 10. Verify the first crawl
|
||||
|
||||
```bash
|
||||
docker compose logs schulcloud-mcp | grep 'scheduled crawl'
|
||||
# [schulcloud-mcp] scheduled crawl: generation 1, 1030 files, … newly extracted, …s
|
||||
```
|
||||
|
||||
A second crawl runs every six hours and downloads only what is new.
|
||||
|
||||
## 11. The monthly token
|
||||
|
||||
The token lasts 30 days. From a week before, the log, `whoami` and
|
||||
`schulcloud token` warn. Replacing it takes a minute and no restart:
|
||||
|
||||
1. Private window → log in → copy the `jwt` cookie (as in step 4).
|
||||
2. `schulcloud token set` and paste it — or open `https://mcp.example.org/token`
|
||||
and paste it there with `MCP_AUTH_TOKEN`.
|
||||
3. Close the private window.
|
||||
|
||||
The server checks the token with Schulcloud before using it, restarts the
|
||||
keepalive, and saves it in the `state` volume, where it outlives restarts and
|
||||
takes precedence over the older one in `.env`.
|
||||
|
||||
The same steps revive a session that lapsed — after a power cut of more than two
|
||||
hours, say.
|
||||
|
||||
## Updating
|
||||
|
||||
A new version is published first, from a development machine:
|
||||
`npm run publish-image` ([DEPLOYMENT.md](DEPLOYMENT.md#publishing-an-image)).
|
||||
Then, on the Pi:
|
||||
|
||||
```bash
|
||||
cd /opt/schulcloud-mcp
|
||||
git pull # compose files and docs
|
||||
docker compose pull # the newly published image
|
||||
docker compose up -d
|
||||
docker compose logs --tail 20 schulcloud-mcp
|
||||
```
|
||||
|
||||
`COMPOSE_FILE` in `.env` keeps applying the Pi settings. Recreating the container
|
||||
keeps the database, the mirror and a replaced token, all of which live in volumes.
|
||||
|
||||
With `SCHULCLOUD_MCP_TAG` pinned, set it to the new commit id before pulling.
|
||||
Going back works the same way: set the previous commit id, then `pull` and
|
||||
`up -d`.
|
||||
|
||||
## Backups
|
||||
|
||||
Everything this server stores is a copy of something upstream — with one
|
||||
exception. **Your own notes are not a copy of anything**, and nothing can
|
||||
rebuild them.
|
||||
|
||||
| What | Needed? |
|
||||
|---|---|
|
||||
| `.env` | **Yes** — it holds every secret. Only ever back it up encrypted. |
|
||||
| **`schulcloud-mcp_notes`** | **Yes. The only irreplaceable thing here** — a note taken in a lesson cannot be retaken. Small: a school year is a few megabytes. |
|
||||
| Postgres | Optional: a crawl rebuilds it. `docker compose exec postgres pg_dump -U schulcloud schulcloud \| gzip > index.sql.gz` |
|
||||
| `schulcloud-mcp_mirror` | No — re-downloaded by the next crawl. |
|
||||
| `schulcloud-mcp_state` | No — a replaced token, expiring within 30 days anyway. |
|
||||
|
||||
```bash
|
||||
docker run --rm -v schulcloud-mcp_notes:/notes:ro -v "$PWD":/out alpine \
|
||||
tar czf /out/notizen-$(date +%F).tar.gz -C /notes .
|
||||
```
|
||||
|
||||
If you bind-mounted a directory you already sync ([NOTES.md](NOTES.md)), back
|
||||
that up instead — and check the sync tool keeps versioned copies rather than
|
||||
assuming it does.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| `keepalive: token rejected (401)` | The session ended: the Pi was off for more than two hours, a Schulportal tab was left open, or 30 days passed | Step 11 |
|
||||
| 401 about two hours after pasting a token | A Schulportal tab still open on that login | Close it, then step 11 |
|
||||
| claude.ai cannot add the connector | DNS, forwarding or certificate not in place; a missing or mistyped request header (401); or, with a secret path, a wrong secret (404) | Step 8's checks, in order, then the header |
|
||||
| The connector token works on `/mcp` but not with the CLI | By design: it is refused on `/api` | The CLI uses `MCP_AUTH_TOKEN` |
|
||||
| claude.ai connects, then tools hang | `flush_interval -1` missing from the Caddy site | Step 6 |
|
||||
| `/mcp` answers 401 with the right token | Whitespace copied along with the token | Re-copy it |
|
||||
| Caddy never obtains a certificate | Port 80 not forwarded, or DNS not yet pointing at the VPS | Step 7 |
|
||||
| `network … declared as external, but could not be found` | Wrong `CADDY_NETWORK` | Step 3 |
|
||||
| `required variable … is missing a value` | A line missing from `.env` | Step 3 |
|
||||
| `unauthorized` or `pull access denied` from `docker compose pull` | Not logged in to the registry on the Pi | `docker login registry.mc02.dev` |
|
||||
| `no matching manifest for linux/arm64` | The image was published for amd64 only | Publish again with `npm run publish-image`, which builds both |
|
||||
| Compose rejects `!reset` in `deploy/docker-compose.pi.yml` | Compose older than 2.24 | Update Docker (step 1) |
|
||||
| `WebUntis rejected the server's key` from a untis_* tool | The key was regenerated in WebUntis, or `UNTIS_USER` does not match it | Copy both again from Profil → Freigaben → Untis Mobile, then `docker compose up -d --force-recreate schulcloud-mcp` |
|
||||
| `the server's clock is too far off` from a untis_* tool | The Pi's clock has drifted; the Untis code is time-based | `timedatectl status`, then fix NTP |
|
||||
| `EACCES` for `/data/state`, `/data/mirror` or `/data/notes` in the logs | A volume created by an old image, owned by root | `docker compose down`, `docker volume rm schulcloud-mcp_state` (or `_mirror`) and `docker compose up -d`. **Never delete `_notes`** — back it up, then fix the ownership: `docker run --rm -v schulcloud-mcp_notes:/n alpine chown -R 1000:1000 /n` |
|
||||
| `/app/` answers 404 | `WEB_PASSWORD` not set, or the container not recreated since it was | `grep ^WEB_PASSWORD= .env`, then `docker compose up -d --force-recreate schulcloud-mcp` |
|
||||
| The app logs you out constantly | The session cookie is marked `Secure` but the connection is not HTTPS, or Caddy's `X-Forwarded-Proto` is being stripped | Reach it over HTTPS; leave that header alone |
|
||||
| Notes exist but `search` cannot find them | Only a **full** crawl reads them | `schulcloud refresh --force` |
|
||||
|
||||
## Security checklist
|
||||
|
||||
- `.env` is mode 600 and in no unencrypted backup.
|
||||
- `docker compose ps` shows no published ports for `schulcloud-mcp` or `schulcloud-mcp-db`.
|
||||
- The registry login in `~/.docker/config.json` is only base64-encoded. Keep the
|
||||
Pi user's home private, and use an account that may only pull if your
|
||||
registry can issue one.
|
||||
- The VPS forwards raw TCP and opens only 80, 443, 51820/udp and SSH.
|
||||
- With a secret path set, it stays out of Caddy's access log — this prints a
|
||||
count, never the secret:
|
||||
```bash
|
||||
docker exec <caddy-container> grep -c "$(grep '^MCP_PATH_SECRET=' .env | cut -d= -f2)" /var/log/caddy/schulcloud-mcp.log
|
||||
# 0
|
||||
```
|
||||
- If claude.ai's token may have leaked: set a new `MCP_CONNECTOR_TOKEN`, run
|
||||
`docker compose up -d --force-recreate schulcloud-mcp`, and re-add the
|
||||
connector with the new header. A leaked secret path is replaced the same way.
|
||||
`MCP_AUTH_TOKEN` stays valid either way.
|
||||
- `WEB_PASSWORD` is the one credential here that can be guessed, so its length
|
||||
is what protects the notes and the Schulcloud token. Changing it and
|
||||
recreating the container logs out every session — that is the revocation
|
||||
mechanism, and the thing to do if a phone is lost.
|
||||
@@ -94,12 +94,48 @@ Genuinely unavailable, not merely uncovered:
|
||||
- **Numeric grades** — the API's `grade` was null on every graded submission
|
||||
here, so that path stays unverified against real data.
|
||||
|
||||
## 4b. A third source: the user's own notes — BUILT
|
||||
|
||||
Measured after a term of use: Schulcloud says what was *uploaded* and WebUntis
|
||||
says what was *scheduled*. Neither says what was *taught* — which point the
|
||||
teacher laboured, which example landed, what "will definitely come up". That is
|
||||
only ever in what the student wrote down, and it was sitting in Apple Notes
|
||||
where nothing could read it.
|
||||
|
||||
Built as a directory of Markdown files (`NOTES_DIR`), read by `list_notes` /
|
||||
`get_note`, indexed as `kind: 'note'`, searched by both the index and the live
|
||||
path, diffed by `what_changed`, and consulted by all three German prompts.
|
||||
`add_note` writes one — the only write in this server, and bounded to that
|
||||
directory by the same `safeComponent`/`resolveWithin` pair that guards the file
|
||||
mirror. `scripts/export-apple-notes.js` plus `schulcloud note import` is the
|
||||
migration path out of Notes.app, which has no export of its own. See
|
||||
`docs/NOTES.md`.
|
||||
|
||||
**Files rather than a table**, deliberately: they have to be writable from a
|
||||
classroom, readable when Postgres is down, and outlive this project.
|
||||
|
||||
## 4c. The class register into the index — BUILT
|
||||
|
||||
`untis_lesson_topics` could already answer "where did we get to" for one series
|
||||
from one period id. What it could not do was answer "what have we done in
|
||||
Deutsch this term", and none of it was searchable.
|
||||
|
||||
Both fall out of one API property: `getLessonTopic2017` answers per *series*, so
|
||||
a term costs one call per lesson series (`core/untis-history.ts`). The tool now
|
||||
takes a subject as well as a period id, and `UNTIS_HISTORY_DAYS` of register —
|
||||
topics, the notes teachers leave on a period, announced tests, homework — go
|
||||
into the index as `kind: 'untis'`.
|
||||
|
||||
## 5. Still open
|
||||
|
||||
- **Video/audio transcription** — this account has 5 MP4s and a WebM that are
|
||||
currently just "here is a file you cannot read".
|
||||
- **MCP resources and prompts** — expose courses/boards as attachable resources;
|
||||
canned prompts such as "summarise this week's homework".
|
||||
- **MCP resources and prompts — partly built.** Courses and rooms are resources,
|
||||
and there are two German prompts (`zusammenfassung`, `pruefungsvorbereitung`).
|
||||
Still open: files as resources — about a thousand of them do not fit a picker
|
||||
that lists every entry, so they need a browsing UI (an MCP App) rather than a
|
||||
longer list — and claude.ai, which cannot reach a server on localhost and does
|
||||
not send a static bearer token.
|
||||
- **Calendar** — a separate `schulcloud-calendar` service, not part of the v3 API
|
||||
mapped in `API.md`.
|
||||
|
||||
|
||||
312
local-instance/README.md
Normal file
312
local-instance/README.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# A local Schulcloud
|
||||
|
||||
A Docker Compose stack that runs a real Schulcloud instance on this machine,
|
||||
modelled on **schulcloud-thueringen.de** — the instance this MCP server reads.
|
||||
|
||||
It exists so that the parts of the product we can only observe from a student's
|
||||
account can also be *produced*: log in as the teacher, grade a submission, then
|
||||
look at it through `list_submissions` and see whether what we render matches
|
||||
what the student is shown.
|
||||
|
||||
> Everything here is a throwaway development instance. The credentials are the
|
||||
> upstream development defaults, published in the upstream repositories. Bind
|
||||
> nothing beyond `127.0.0.1`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd local-instance
|
||||
docker compose up -d # ~5 min on a cold cache
|
||||
./scripts/seed.sh # loads the demo school; takes another minute or two
|
||||
```
|
||||
|
||||
Then open <http://localhost:4400> and sign in as
|
||||
`demo-schueler@schul-cloud.org` / `schulcloud` (Fritz Schmidt, a student with
|
||||
graded submissions). `seed.sh` prints the full account list on completion.
|
||||
|
||||
> The demo password differs by account — an upstream quirk, not ours. The two
|
||||
> `demo-*` accounts use `schulcloud`, the built-in `admin`/`lehrer` and the
|
||||
> teacher `klara.fall` use `Schulcloud1!`, and the `*.qa` accounts use
|
||||
> `Schulcloud1qa!`.
|
||||
|
||||
To add the remaining external tools (H5P, tldraw, Collabora):
|
||||
|
||||
```bash
|
||||
docker compose --profile tools up -d
|
||||
```
|
||||
|
||||
## Why the images are the interesting part
|
||||
|
||||
`quay.io/schulcloudverbund/*` are the **same images the live instance runs**,
|
||||
built for the `thr` theme, and they are public. So this is not a rebuild of
|
||||
`main` that may have drifted — it is the deployed artefact, pinned to the
|
||||
version the live instance reports:
|
||||
|
||||
```console
|
||||
$ curl -s https://schulcloud-thueringen.de/version
|
||||
{"client": {"version": "33.40.0"}, "nuxt-client": {"version": "33.40.1"},
|
||||
"server": {"version": "33.40.2"}, "dof_app_deploy": "33.40.2"}
|
||||
```
|
||||
|
||||
Hence `SC_VERSION` defaults to `33.40`. To pin something else:
|
||||
|
||||
```bash
|
||||
SC_VERSION=33.27 docker compose up -d
|
||||
```
|
||||
|
||||
`CLAUDE.md` says *live behaviour beats upstream source* — the clones in
|
||||
`vendor/` track `main` and may be ahead of what is deployed. Running the
|
||||
deployed images is how that rule is honoured here rather than worked around.
|
||||
|
||||
## What is faithful, and what is not
|
||||
|
||||
Faithful, and load-bearing for what we test:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Images** | the live instance's own, `thr` theme, tag `33.40` |
|
||||
| **Feature flags** | `env/api.env` is a replay of `GET /api/v3/config/public` from the live instance, not a hand-picked set |
|
||||
| **Instance identity** | `env/shared.env` mirrors `dof_app_deploy/ansible/group_vars/thr/instance_cfg.yml` |
|
||||
| **URL routing** | `proxy/nginx.conf` is generated from the deployment's own ingress table (see below) |
|
||||
| **Session model** | Valkey in `single` mode, so the JWT whitelist expires sessions exactly as production does |
|
||||
| **Seed data** | the upstream demo school, including graded and ungraded submissions |
|
||||
|
||||
Deliberately different:
|
||||
|
||||
- **No external OAuth / Schulportal login.** Excluded by request, and it is the
|
||||
one part that cannot be stood up locally. Local login is username + password
|
||||
against the seeded accounts.
|
||||
- **No BigBlueButton.** `FEATURE_VIDEOCONFERENCE_ENABLED` is off; upstream it is
|
||||
on. BBB is a separate product of its own scale.
|
||||
- **`FEATURE_CONSENT_NECESSARY=false`**, or every seeded user hits a consent
|
||||
wall before reaching any content.
|
||||
- **No LDAP / TSP sync, no Nextcloud, no calendar service.**
|
||||
- **Antivirus off by default** — see the `av` profile.
|
||||
|
||||
Every one of these is marked in the env files at the line it affects.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
docker-compose.yml the stack; profiles: (default) | tools | av | preview
|
||||
(minio-loopback: see "Simulating a teacher")
|
||||
env/ one file per service, all values local-only
|
||||
proxy/nginx.conf GENERATED — the single origin, see scripts/gen-proxy-conf.py
|
||||
scripts/seed.sh loads the demo school via the management app
|
||||
scripts/minio-init.sh creates the S3 buckets each service expects
|
||||
etherpad/APIKEY.txt fixed Etherpad API key, matching env/api.env
|
||||
```
|
||||
|
||||
### Ports
|
||||
|
||||
| Port | |
|
||||
|---|---|
|
||||
| **4400** | **the instance** — everything a browser touches |
|
||||
| 3030 | server API, direct |
|
||||
| 3333 | management app (seeding only, not part of a running instance) |
|
||||
| 3100 / 4000 | legacy client / SPA, direct |
|
||||
| 4444 | file-storage |
|
||||
| 9900 / 9901 | MinIO S3 API / console (`miniouser` / `miniouser`) |
|
||||
| 9980 | Collabora (`tools` profile) |
|
||||
| 27019 | MongoDB |
|
||||
| 6381 | Valkey |
|
||||
| 15673 | RabbitMQ management |
|
||||
|
||||
Everything binds to `127.0.0.1`. Port numbers are shifted off their upstream
|
||||
defaults where those commonly collide (Mongo, Valkey, RabbitMQ, MinIO) — note
|
||||
`docker-compose.override.yml` in the repo root already uses 8080 and 55432.
|
||||
|
||||
## The proxy is generated, not written
|
||||
|
||||
The live instance is one origin whose paths are split across the legacy client,
|
||||
the SPA, and several APIs. That split is not cosmetic: `/rooms/courses-list` is
|
||||
the SPA, `/courses/:id` is the legacy client, and `/api/v3/file/` is a
|
||||
different service from `/api/v3/`. Get it wrong and you are testing a different
|
||||
application from the one students use.
|
||||
|
||||
The rules live in `dof_app_deploy/ansible/group_vars/all/x_ingress.yml` (46 of
|
||||
them) plus a per-path ingress inside each service repo. Transcribing that by
|
||||
hand invites drift, so `scripts/gen-proxy-conf.py` reads the deployment repo and
|
||||
emits `proxy/nginx.conf`:
|
||||
|
||||
```bash
|
||||
python3 scripts/gen-proxy-conf.py > proxy/nginx.conf
|
||||
docker compose restart proxy
|
||||
```
|
||||
|
||||
It needs the upstream clones in `../vendor` (gitignored — see the root README),
|
||||
which is why the output is committed.
|
||||
|
||||
Two details in there are worth keeping:
|
||||
|
||||
- Every `proxy_pass` goes through a **variable plus a `resolver`**, so names are
|
||||
resolved per request. With a literal upstream, nginx refuses to start
|
||||
whenever an optional profile is down — which is the normal case.
|
||||
- **Etherpad gets the deployment's own rewrite rules**, not a plain
|
||||
`proxy_pass`. It is mounted under a prefix it knows nothing about; without the
|
||||
rewrites a pad loads and then silently never syncs.
|
||||
|
||||
## Seeding
|
||||
|
||||
`scripts/seed.sh` does what the real deployment's init job does: it asks the
|
||||
management app to load `backup/setup/*.json`, which ships **inside the server
|
||||
image**. It also registers MinIO in the `storageproviders` collection, which has
|
||||
no seed data on purpose (it holds credentials) and without which legacy file
|
||||
uploads fail.
|
||||
|
||||
Re-running it is safe; collections are replaced, not appended to.
|
||||
|
||||
### What the demo data already contains
|
||||
|
||||
The seed includes 50 tasks and 24 submissions covering the grading states that
|
||||
are otherwise hard to obtain — including the two this server renders:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `graded: true`, `grade: null`, `gradeComment` set | graded by feedback alone |
|
||||
| `graded: true`, `grade: 100`, `gradeComment` set | a percentage **and** feedback |
|
||||
| `graded: false`, `submitted: true` | handed in, not yet looked at |
|
||||
|
||||
The second row matters: no submission in the real account has ever had a
|
||||
numeric grade, so `formatGradeState`'s percentage branch had never been seen
|
||||
against real data. Here it can be.
|
||||
|
||||
## Simulating a teacher
|
||||
|
||||
`scripts/simulate-teacher.mjs` creates, edits and deletes the things teachers
|
||||
create, so the MCP server can be run against content the real account has never
|
||||
held. It is the only thing in this repository that writes to a Schulcloud, and
|
||||
it refuses to run against anything but a localhost address.
|
||||
|
||||
```bash
|
||||
node scripts/simulate-teacher.mjs create # course, room, topic, task, board,
|
||||
# columns, cards, rich text, link,
|
||||
# Etherpad pad, folder, files
|
||||
node scripts/simulate-teacher.mjs update # rename and rewrite all of it
|
||||
node scripts/simulate-teacher.mjs delete # remove it again
|
||||
node scripts/simulate-teacher.mjs files # only the file-manager part, onto an existing fixture
|
||||
```
|
||||
|
||||
It also fills the **file manager** ("Dateien") — the legacy file system behind
|
||||
Persönliche, Kurs-, Team- and Geteilte Dateien, a separate store from the board
|
||||
files above — with a course folder tree ("Arbeitsblätter/Woche 1"), a team
|
||||
folder, a folder in the demo student's own files, and a teacher's file shared
|
||||
read-only with the student. The student is added to the adopted team so the
|
||||
team files are visible from the account under test. Every file carries a unique
|
||||
search term, so search over the index can be checked per area.
|
||||
|
||||
Legacy file uploads only work because of two changes to the stack:
|
||||
|
||||
- **`minio-loopback`.** The legacy service signs upload and download URLs for
|
||||
its storage provider's single endpoint, and uses that endpoint for its own S3
|
||||
calls. `minio:9000` works inside the compose network and nowhere else, so the
|
||||
browser and the MCP server on the host were handed URLs they could not open.
|
||||
`seed.sh` now registers `http://localhost:9900`, and this socat sidecar,
|
||||
sharing the api container's network namespace, forwards that address to
|
||||
MinIO — the same url then works from the api, the browser and the host.
|
||||
After recreating `api`, recreate `minio-loopback` too: it lives in api's
|
||||
network namespace.
|
||||
- **The school bucket is created up front.** The legacy service makes
|
||||
`bucket-<schoolId>` on first upload and then calls `PutBucketCors`, which
|
||||
MinIO does not implement, so the first upload failed with *"A header you
|
||||
provided implies functionality that is not implemented"*. `minio-init.sh`
|
||||
creates the demo school's bucket, and an existing bucket skips both calls.
|
||||
|
||||
Ids are kept in `.simulate-teacher.json` between phases, so the MCP server can
|
||||
be pointed at the instance in between:
|
||||
|
||||
```bash
|
||||
eval "$(./scripts/mcp-env.sh)" # as the demo student
|
||||
cd .. && npm run smoke # 82 checks against the local instance
|
||||
```
|
||||
|
||||
`mcp-env.sh` points the index at its own database, `schulcloud_local`, and the
|
||||
mirror at `tmp/mirror-local` — not just the instance at this one, and it switches
|
||||
WebUntis off, since this instance has no timetable and the key in the root `.env`
|
||||
is the real school's. The root
|
||||
`.env` normally targets the live account, and process env beats `--env-file`, so
|
||||
without that a local smoke run would crawl these fixtures into the live index,
|
||||
where a per-course refresh then carries them forward indefinitely. Create the
|
||||
database once:
|
||||
|
||||
```bash
|
||||
docker exec schulcloud-mcp-db psql -U schulcloud -d postgres \
|
||||
-c "CREATE DATABASE schulcloud_local OWNER schulcloud"
|
||||
```
|
||||
|
||||
Without it the server does not fail; it runs live-only, and smoke reports the
|
||||
smaller, index-free check count.
|
||||
|
||||
It also builds a **room** ("Raum"): rooms are a separate space from courses,
|
||||
and the naming misleads — the sidebar's *Kurse* entry points at
|
||||
`/rooms/courses-overview` while *Räume* points at `/rooms`. The fixture adds the
|
||||
demo student to one room and leaves a second room without them, so "only the
|
||||
rooms I belong to" is testable rather than assumed.
|
||||
|
||||
Two things it does **not** do, because the API does not allow them:
|
||||
|
||||
- **Teams cannot be created.** The legacy service registers
|
||||
`['find','get','update','patch','remove']` and no `create`, and v3 has no team
|
||||
route beyond news and create-room; `POST /teams` answers 405. The script
|
||||
adopts a seeded team and edits that instead.
|
||||
- **A board is created unpublished.** Students get 403 on it while the course
|
||||
page still lists its title, so `create` publishes the main board and leaves a
|
||||
second one as a draft on purpose — both states are worth testing against.
|
||||
|
||||
`seed.sh` also drags the demo data into the present. The seed ships courses that
|
||||
ended in 2018 and homework due in 2017, and the v3 endpoints filter on those
|
||||
dates: a student sees no tasks at all in an ended course, which makes the whole
|
||||
student-facing surface look empty for reasons that have nothing to do with the
|
||||
code under test.
|
||||
|
||||
## Profiles
|
||||
|
||||
| Profile | Services | Cost |
|
||||
|---|---|---|
|
||||
| *(default)* | Mongo, Valkey, RabbitMQ, MinIO, api, management, board-collaboration, admin-api, file-storage, client, nuxt, Etherpad, proxy | ~4.7 GB images |
|
||||
| `tools` | H5P editor + static files + library install, tldraw server + worker, Collabora | ~3.3 GB more, Collabora is the bulk |
|
||||
| `av` | ClamAV + Clammit | ~1.5 GB resident for the signature database |
|
||||
| `preview` | file-preview generator (thumbnails) | small |
|
||||
|
||||
Etherpad is in the default profile even though it is an external tool. The
|
||||
legacy client requests an Etherpad session on **every** topic page whose lesson
|
||||
has contents — it never checks whether a pad is actually present. With Etherpad
|
||||
down that call fails, `validUntil` comes back undefined, and Express rejects the
|
||||
resulting session cookie with *"option expires is invalid"*: a 500 on every
|
||||
topic page, pad or no pad. The live deployment always runs it, so this is the
|
||||
faithful configuration as well as the working one.
|
||||
|
||||
`av` is opt-in for a reason beyond size: with `ENABLE_FILE_SECURITY_CHECK=true`
|
||||
but no scanner reachable, every upload stays at
|
||||
`securityCheck.status=pending` and can never be downloaded. Turn the flag in
|
||||
`env/file-storage.env` on **together with** the profile, or neither.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Everything 502s.** The proxy is up before the apps are. `docker compose logs
|
||||
-f api` — the server waits for Mongo to become primary.
|
||||
|
||||
**Mongo never becomes healthy.** It runs as a single-node replica set because
|
||||
the migration runner opens transactions, which Mongo refuses on a standalone.
|
||||
The healthcheck initiates the set on first boot; give it ~30 s.
|
||||
|
||||
**Login succeeds, then immediately bounces back.** The `jwt` cookie is being
|
||||
dropped. Check `COOKIE__SECURE=false` in `env/client.env` — the proxy speaks
|
||||
plain HTTP locally.
|
||||
|
||||
**A file uploads but will not download.** See the `av` note above.
|
||||
|
||||
**Previews never appear, and `/api/v3/file/preview/...` answers 404
|
||||
PREVIEW_NOT_POSSIBLE.** Two causes, both local. First, with no virus scanner
|
||||
(see the `av` note) every upload stays `securityCheck.status=pending`, and a
|
||||
record that has not been scanned reports `previewStatus: awaiting_scan_status`
|
||||
— previews are gated on the scan. Second, the `file-preview` image ships an
|
||||
ImageMagick policy written for an older ImageMagick than the 7.1.2 it actually
|
||||
contains, so every coder it needs is denied and each attempt fails with
|
||||
*"attempt to perform an operation not authorized by the security policy"* —
|
||||
which the API surfaces as a 404. `file-preview/policy.xml` is mounted over the
|
||||
image's own to fix the second; the first is inherent to running without `av`.
|
||||
|
||||
**H5P element stays empty.** `docker compose --profile tools run --rm
|
||||
h5p-libraries` and watch it finish; the editor has nothing to offer until the
|
||||
content types are in the bucket.
|
||||
318
local-instance/docker-compose.yml
Normal file
318
local-instance/docker-compose.yml
Normal file
@@ -0,0 +1,318 @@
|
||||
# A local Schulcloud, as close to schulcloud-thueringen.de as it can be made
|
||||
# without its external identity provider.
|
||||
#
|
||||
# The application images are the *same* images the real instance runs
|
||||
# (quay.io/schulcloudverbund, thr theme, tag 33.40 — see README.md), so the
|
||||
# behaviour under test is the deployed behaviour, not a rebuild of main.
|
||||
#
|
||||
# docker compose up -d core: login, courses, boards, files
|
||||
# docker compose --profile tools up -d + h5p, tldraw, collabora
|
||||
# docker compose --profile av up -d + virus scanning of uploads
|
||||
#
|
||||
# Everything here is a throwaway dev instance: the credentials are the upstream
|
||||
# development defaults and are published in the upstream repositories. Do not
|
||||
# expose any of it beyond localhost.
|
||||
|
||||
x-sc-version: &sc-version "${SC_VERSION:-33.40}"
|
||||
|
||||
x-server-image: &server-image
|
||||
image: quay.io/schulcloudverbund/schulcloud-server:${SC_VERSION:-33.40}
|
||||
env_file: [env/shared.env, env/jwt.env, env/api.env]
|
||||
depends_on:
|
||||
mongo: {condition: service_healthy}
|
||||
valkey: {condition: service_started}
|
||||
rabbitmq: {condition: service_healthy}
|
||||
restart: unless-stopped
|
||||
|
||||
services:
|
||||
# ---------------------------------------------------------------- infra ---
|
||||
mongo:
|
||||
image: docker.io/mongo:7
|
||||
# Single-node replica set rather than a bare mongod: the server's migration
|
||||
# runner opens transactions, which mongo refuses outside a replica set.
|
||||
command: ["--replSet", "rs0", "--bind_ip_all"]
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
ports: ["127.0.0.1:27019:27017"]
|
||||
healthcheck:
|
||||
# Initiates the replica set on first start and reports healthy once the
|
||||
# node is actually primary, which is what every other service waits for.
|
||||
test: >-
|
||||
mongosh --quiet --eval '
|
||||
try { rs.status() } catch (e) { rs.initiate({_id:"rs0",members:[{_id:0,host:"mongo:27017"}]}) }
|
||||
quit(db.hello().isWritablePrimary ? 0 : 1)'
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 30
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
valkey:
|
||||
# The JWT whitelist. Sessions die when their key expires, exactly as in
|
||||
# production — this is the piece that makes local session testing honest.
|
||||
image: docker.io/valkey/valkey:8-alpine
|
||||
ports: ["127.0.0.1:6381:6379"]
|
||||
restart: unless-stopped
|
||||
|
||||
rabbitmq:
|
||||
image: docker.io/rabbitmq:4-management-alpine
|
||||
ports: ["127.0.0.1:15673:15672"]
|
||||
healthcheck:
|
||||
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 20
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
|
||||
minio:
|
||||
# Stands in for the S3 provider the real instance uses. Buckets are created
|
||||
# by minio-init below.
|
||||
image: quay.io/minio/minio:latest
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: miniouser
|
||||
MINIO_ROOT_PASSWORD: miniouser
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
ports:
|
||||
- "127.0.0.1:9900:9000" # S3 API
|
||||
- "127.0.0.1:9901:9001" # console (miniouser / miniouser)
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
restart: unless-stopped
|
||||
|
||||
minio-init:
|
||||
image: quay.io/minio/mc:latest
|
||||
depends_on:
|
||||
minio: {condition: service_healthy}
|
||||
entrypoint: ["/bin/sh", "/init.sh"]
|
||||
volumes:
|
||||
- ./scripts/minio-init.sh:/init.sh:ro
|
||||
restart: "no"
|
||||
|
||||
# ------------------------------------------------------- schulcloud api ---
|
||||
api:
|
||||
<<: *server-image
|
||||
container_name: sc-api
|
||||
command: ["dist/apps/server/apps/server.app"]
|
||||
ports: ["127.0.0.1:3030:3030"]
|
||||
|
||||
management:
|
||||
# Not part of the running instance — it exposes the seeding and migration
|
||||
# endpoints that the real deployment's init job calls, and nothing else.
|
||||
<<: *server-image
|
||||
command: ["dist/apps/server/apps/management.app"]
|
||||
# Port and base path are hardcoded to 3333 and /api in management.app.ts;
|
||||
# PORT is not read here.
|
||||
ports: ["127.0.0.1:3333:3333"]
|
||||
|
||||
board-collaboration:
|
||||
# The websocket behind column boards. Without it a board renders once and
|
||||
# then never updates.
|
||||
<<: *server-image
|
||||
command: ["dist/apps/server/apps/board-collaboration.app"]
|
||||
environment:
|
||||
PORT: "4450"
|
||||
|
||||
admin-api:
|
||||
<<: *server-image
|
||||
command: ["dist/apps/server/apps/admin-api-server.app"]
|
||||
environment:
|
||||
PORT: "4030"
|
||||
|
||||
file-storage:
|
||||
image: quay.io/schulcloudverbund/file-storage:${SC_VERSION:-33.40}
|
||||
env_file: [env/shared.env, env/jwt.env, env/file-storage.env]
|
||||
depends_on:
|
||||
mongo: {condition: service_healthy}
|
||||
rabbitmq: {condition: service_healthy}
|
||||
minio: {condition: service_healthy}
|
||||
ports: ["127.0.0.1:4444:4444"]
|
||||
restart: unless-stopped
|
||||
|
||||
file-storage-consumer:
|
||||
# The AMQP half of files-storage, and a separate entrypoint from the HTTP
|
||||
# one: only `files-storage-consumer.app` registers FilesStorageConsumer, so
|
||||
# running the HTTP app alone leaves the `files-storage` exchange with no
|
||||
# queue bound to it.
|
||||
#
|
||||
# The symptom is not a file problem. TaskService.delete awaits
|
||||
# deleteFilesOfParent over AMQP before touching the task, so with nothing
|
||||
# consuming, deleting a task or a topic hangs until the request timeout and
|
||||
# answers 408 REQUEST_TIMEOUT with the entity still there. Copying a course
|
||||
# goes the same way.
|
||||
image: quay.io/schulcloudverbund/file-storage:${SC_VERSION:-33.40}
|
||||
command: ["dist/apps/files-storage-consumer.app.js"]
|
||||
env_file: [env/shared.env, env/jwt.env, env/file-storage.env]
|
||||
depends_on:
|
||||
mongo: {condition: service_healthy}
|
||||
rabbitmq: {condition: service_healthy}
|
||||
minio: {condition: service_healthy}
|
||||
restart: unless-stopped
|
||||
|
||||
minio-loopback:
|
||||
# The legacy file service (the file manager: Persönliche/Kurs-/Team-Dateien)
|
||||
# signs upload and download URLs for its storage provider's one endpoint, and
|
||||
# the same endpoint serves its own S3 calls. "minio:9000" works inside the
|
||||
# compose network and nowhere else, so a browser or the MCP server on this
|
||||
# machine was handed URLs it could not open: uploads through the UI failed
|
||||
# and downloads could not be tested. seed.sh registers the provider as
|
||||
# http://localhost:9900 instead, and this forwards that address to MinIO from
|
||||
# inside the api container's own network namespace — so the one URL now
|
||||
# works from the api, from the browser and from the host alike.
|
||||
image: alpine/socat:1.8.0.1
|
||||
network_mode: "service:api"
|
||||
command: ["TCP-LISTEN:9900,fork,reuseaddr,bind=127.0.0.1", "TCP:minio:9000"]
|
||||
depends_on:
|
||||
api: {condition: service_started}
|
||||
minio: {condition: service_healthy}
|
||||
restart: unless-stopped
|
||||
|
||||
file-preview:
|
||||
# Generates thumbnails via ImageMagick, driven off RabbitMQ. Optional: with
|
||||
# it absent, files still upload and download, they just have no preview.
|
||||
image: quay.io/schulcloudverbund/file-storage:file-preview-${SC_VERSION:-33.40}
|
||||
profiles: ["preview"]
|
||||
env_file: [env/shared.env, env/jwt.env, env/file-storage.env]
|
||||
volumes:
|
||||
# The image's own ImageMagick policy denies every coder it needs; see the
|
||||
# comment in the file. Without this the profile runs but produces nothing.
|
||||
- ./file-preview/policy.xml:/etc/ImageMagick-7/policy.xml:ro
|
||||
depends_on:
|
||||
rabbitmq: {condition: service_healthy}
|
||||
minio: {condition: service_healthy}
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------------------- clients ---
|
||||
client:
|
||||
# The legacy UI. Still owns "/" and much of the course view.
|
||||
image: quay.io/schulcloudverbund/schulcloud-client-thr:${SC_VERSION:-33.40}
|
||||
env_file: [env/shared.env, env/jwt.env, env/client.env]
|
||||
depends_on: [api]
|
||||
ports: ["127.0.0.1:3100:3100"]
|
||||
restart: unless-stopped
|
||||
|
||||
nuxt:
|
||||
# The Vue SPA, built for the thr theme. The image is an nginx that
|
||||
# templates env vars into its config at start.
|
||||
image: quay.io/schulcloudverbund/schulcloud-frontend-thr:${SC_VERSION:-33.40}
|
||||
env_file: [env/nuxt.env]
|
||||
ports: ["127.0.0.1:4000:4000"]
|
||||
restart: unless-stopped
|
||||
|
||||
etherpad:
|
||||
# The collaborative text editor element.
|
||||
#
|
||||
# Core, not a "tool", however much it looks like one: the legacy client asks
|
||||
# the server for an Etherpad session on *every* topic page whose lesson has
|
||||
# contents, without checking whether the lesson contains a pad at all
|
||||
# (controllers/topics.js builds `etherpadPads` and then never reads it).
|
||||
# Unreachable, that call fails, `validUntil` arrives undefined, and
|
||||
# `new Date(undefined * 1000)` makes Express reject the session cookie —
|
||||
# "option expires is invalid", a 500 on every topic page. The live
|
||||
# deployment always runs Etherpad (ETHERPAD_REPLICAS: 1), so keeping it in
|
||||
# the default profile is both the working and the faithful choice.
|
||||
image: docker.io/etherpad/etherpad:3.3.3
|
||||
env_file: [env/etherpad.env]
|
||||
volumes:
|
||||
- ./etherpad/APIKEY.txt:/opt/etherpad-lite/APIKEY.txt:ro
|
||||
depends_on:
|
||||
mongo: {condition: service_healthy}
|
||||
restart: unless-stopped
|
||||
|
||||
proxy:
|
||||
# The single origin. Everything a browser touches goes through here, so the
|
||||
# app sees one host the way it does in production.
|
||||
image: docker.io/nginx:1.29-alpine
|
||||
volumes:
|
||||
- ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
ports: ["127.0.0.1:4400:4400"]
|
||||
depends_on: [api, client, nuxt]
|
||||
restart: unless-stopped
|
||||
|
||||
# -------------------------------------------------- external tools -------
|
||||
h5p-editor:
|
||||
image: quay.io/schulcloudverbund/h5p-server:${SC_VERSION:-33.40}
|
||||
profiles: ["tools"]
|
||||
command: ["dist/apps/h5p-editor.app"]
|
||||
env_file: [env/shared.env, env/jwt.env, env/h5p.env]
|
||||
environment:
|
||||
PORT: "4448"
|
||||
depends_on:
|
||||
mongo: {condition: service_healthy}
|
||||
minio: {condition: service_healthy}
|
||||
restart: unless-stopped
|
||||
|
||||
h5p-staticfiles:
|
||||
image: quay.io/schulcloudverbund/h5p-server:static-files-${SC_VERSION:-33.40}
|
||||
profiles: ["tools"]
|
||||
restart: unless-stopped
|
||||
|
||||
h5p-libraries:
|
||||
# One-shot: installs the H5P content types listed in env/h5p.env into the
|
||||
# library bucket. Exits when done; re-run it after changing that list.
|
||||
image: quay.io/schulcloudverbund/h5p-server:${SC_VERSION:-33.40}
|
||||
profiles: ["tools"]
|
||||
command: ["dist/apps/h5p-library-management.app"]
|
||||
env_file: [env/shared.env, env/jwt.env, env/h5p.env]
|
||||
depends_on:
|
||||
mongo: {condition: service_healthy}
|
||||
minio-init: {condition: service_completed_successfully}
|
||||
restart: "no"
|
||||
|
||||
tldraw-server:
|
||||
# The whiteboard element.
|
||||
image: quay.io/schulcloudverbund/tldraw-server:${SC_VERSION:-33.40}
|
||||
profiles: ["tools"]
|
||||
command: ["dist/apps/tldraw-server.app.js"]
|
||||
env_file: [env/shared.env, env/jwt.env, env/tldraw.env]
|
||||
depends_on: [valkey, minio]
|
||||
restart: unless-stopped
|
||||
|
||||
tldraw-worker:
|
||||
image: quay.io/schulcloudverbund/tldraw-server:${SC_VERSION:-33.40}
|
||||
profiles: ["tools"]
|
||||
command: ["dist/apps/tldraw-worker.app.js"]
|
||||
env_file: [env/shared.env, env/jwt.env, env/tldraw.env]
|
||||
depends_on: [valkey, minio]
|
||||
restart: unless-stopped
|
||||
|
||||
collabora:
|
||||
# Office document editing. Reached by the browser directly on :9980, the
|
||||
# way the real deployment puts it on its own hostname.
|
||||
image: docker.io/collabora/code:latest
|
||||
profiles: ["tools"]
|
||||
environment:
|
||||
extra_params: --o:ssl.enable=false --o:ssl.termination=false
|
||||
domain: ".*"
|
||||
aliasgroup1: "http://localhost:4400"
|
||||
ports: ["127.0.0.1:9980:9980"]
|
||||
cap_add: ["MKNOD"]
|
||||
restart: unless-stopped
|
||||
|
||||
# ------------------------------------------------------------ antivirus ---
|
||||
clamav:
|
||||
# ~1.5 GB resident once the signature database loads, hence its own profile.
|
||||
image: docker.io/clamav/clamav:1.5.3
|
||||
profiles: ["av"]
|
||||
volumes:
|
||||
- clamav-db:/var/lib/clamav
|
||||
restart: unless-stopped
|
||||
|
||||
clammit:
|
||||
image: ghcr.io/dbildungsplattform/clammit:0.9.1
|
||||
profiles: ["av"]
|
||||
environment:
|
||||
CLAMMIT_CLAMD_URL: tcp://clamav:3310
|
||||
CLAMMIT_LISTEN: 0.0.0.0:8438
|
||||
depends_on: [clamav]
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
mongo-data:
|
||||
minio-data:
|
||||
clamav-db:
|
||||
100
local-instance/env/api.env
vendored
Normal file
100
local-instance/env/api.env
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
# The schulcloud-server API app (apps/server.app).
|
||||
#
|
||||
# The FEATURE_* block below is not hand-picked: it is a replay of
|
||||
# GET /api/v3/config/public from the live schulcloud-thueringen.de, so the local
|
||||
# instance exposes the same surface to students that the real one does.
|
||||
# Regenerate with ./scripts/sync-feature-flags.sh.
|
||||
|
||||
PORT=3030
|
||||
|
||||
# Internal service addresses (compose DNS), not the browser-facing origin.
|
||||
API_HOST=http://api:3030/api
|
||||
FILES_STORAGE__SERVICE_BASE_URL=http://file-storage:4444
|
||||
LICENSE_SUMMARY_URL=http://api:3030/api/licenses/summary
|
||||
ROOM_MEMBER_INFO_URL=http://api:3030/api/rooms/member-info
|
||||
|
||||
ALERT_STATUS_URL=https://status.schulcloud-thueringen.de/
|
||||
CALENDAR_SERVICE_ENABLED=false
|
||||
NEST_LOG_LEVEL=info
|
||||
|
||||
# Etherpad (see ../etherpad/APIKEY.txt)
|
||||
ETHERPAD__URI=http://etherpad:9001/api/1
|
||||
ETHERPAD__PAD_URI=http://localhost:4400/etherpad/p
|
||||
ETHERPAD__API_KEY=381d67e6347d235ac9446da3ea10a82efd6f8ae09fa2e90efeda80f82feeb4fd
|
||||
|
||||
# tldraw + admin API
|
||||
TLDRAW__WEBSOCKET_URL=ws://localhost:4400/tldraw-server
|
||||
TLDRAW_ADMIN_API_CLIENT__BASE_URL=http://tldraw-server:3349
|
||||
TLDRAW_ADMIN_API_CLIENT__API_KEY=tldraw-admin-key
|
||||
ADMIN_API__ALLOWED_API_KEYS=thisisasupersecureapikeythatisabsolutelysave
|
||||
|
||||
# Teacher/student visibility, as configured for thr.
|
||||
TEACHER_STUDENT_VISIBILITY__IS_CONFIGURABLE=false
|
||||
TEACHER_STUDENT_VISIBILITY__IS_ENABLED_BY_DEFAULT=true
|
||||
TEACHER_STUDENT_VISIBILITY__IS_VISIBLE=false
|
||||
|
||||
# --- feature flags, mirrored from the live instance ----------------------
|
||||
FEATURE_ADMINISTRATE_ROOMS_ENABLED=true
|
||||
FEATURE_AI_TUTOR_ENABLED=false
|
||||
FEATURE_ALLOW_INSECURE_LDAP_URL_ENABLED=false
|
||||
FEATURE_BOARD_LAYOUT_ENABLED=true
|
||||
FEATURE_BOARD_READERS_CAN_EDIT_TOGGLE=true
|
||||
FEATURE_COLUMN_BOARD_COLLABORATIVE_TEXT_EDITOR_ENABLED=true
|
||||
FEATURE_COLUMN_BOARD_COLLABORA_ENABLED=true
|
||||
FEATURE_COLUMN_BOARD_ENABLED=true
|
||||
FEATURE_COLUMN_BOARD_EXTERNAL_TOOLS_ENABLED=true
|
||||
FEATURE_COLUMN_BOARD_FILE_FOLDER_ENABLED=true
|
||||
FEATURE_COLUMN_BOARD_H5P_ENABLED=true
|
||||
FEATURE_COLUMN_BOARD_LINK_ELEMENT_ENABLED=true
|
||||
FEATURE_COLUMN_BOARD_SHARE=true
|
||||
FEATURE_COLUMN_BOARD_SOCKET_ENABLED=true
|
||||
# local override (live: true) — needs infrastructure we do not run
|
||||
FEATURE_COLUMN_BOARD_VIDEOCONFERENCE_ENABLED=false
|
||||
FEATURE_COMMON_CARTRIDGE_COURSE_EXPORT_ENABLED=false
|
||||
FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_ENABLED=false
|
||||
FEATURE_COMMON_CARTRIDGE_COURSE_IMPORT_MAX_FILE_SIZE=1073741824
|
||||
# local override (live: true) — otherwise every seeded user hits a consent wall
|
||||
FEATURE_CONSENT_NECESSARY=false
|
||||
FEATURE_COPY_SERVICE_ENABLED=true
|
||||
FEATURE_COURSE_SHARE=true
|
||||
FEATURE_CTL_TOOLS_COPY_ENABLED=true
|
||||
FEATURE_ENABLE_LDAP_SYNC_DURING_MIGRATION=false
|
||||
FEATURE_EXTERNAL_PERSON_REGISTRATION_ENABLED=false
|
||||
FEATURE_EXTERNAL_SYSTEM_LOGOUT_ENABLED=false
|
||||
FEATURE_FWU_CONTENT_ENABLED=false
|
||||
FEATURE_LESSON_SHARE=true
|
||||
FEATURE_LOGIN_LINK_ENABLED=false
|
||||
FEATURE_MEDIA_SHELF_ENABLED=true
|
||||
FEATURE_NOTIFICATIONS_ENABLED=false
|
||||
FEATURE_PREFERRED_CTL_TOOLS_ENABLED=true
|
||||
FEATURE_ROOM_ADD_EXTERNAL_PERSONS_ENABLED=false
|
||||
FEATURE_ROOM_COPY_ENABLED=true
|
||||
FEATURE_ROOM_LINK_INVITATION_EXTERNAL_PERSONS_ENABLED=false
|
||||
FEATURE_ROOM_REGISTER_EXTERNAL_PERSONS_ENABLED=false
|
||||
FEATURE_ROOM_SHARE=true
|
||||
FEATURE_SCHOOL_POLICY_ENABLED_NEW=true
|
||||
FEATURE_SCHOOL_TERMS_OF_USE_ENABLED=true
|
||||
FEATURE_SCHULCONNEX_COURSE_SYNC_ENABLED=false
|
||||
FEATURE_SCHULCONNEX_MEDIA_LICENSE_ENABLED=false
|
||||
FEATURE_SHOW_MIGRATION_WIZARD=false
|
||||
FEATURE_SHOW_OUTDATED_USERS=false
|
||||
FEATURE_TASK_SHARE=true
|
||||
FEATURE_TEAMS_ENABLED=true
|
||||
FEATURE_TEAM_CREATE_ROOM_ENABLED=true
|
||||
FEATURE_TLDRAW_ENABLED=true
|
||||
FEATURE_USER_LOGIN_MIGRATION_ENABLED=false
|
||||
FEATURE_USER_MIGRATION_ENABLED=false
|
||||
# local override (live: true) — needs infrastructure we do not run
|
||||
FEATURE_VIDEOCONFERENCE_ENABLED=false
|
||||
FEATURE_VIDIS_MEDIA_ACTIVATIONS_ENABLED=false
|
||||
|
||||
# --- required-but-unused endpoints ---------------------------------------
|
||||
# The config classes validate these as present strings even when the feature
|
||||
# is off, so they get a placeholder rather than a real service. Hydra is the
|
||||
# OAuth2 provider behind external tool launches; we do not run it.
|
||||
HYDRA_URI=http://hydra.invalid:4444
|
||||
|
||||
# Calendar. The live deployment runs a schulcloud-calendar service and this
|
||||
# stack does not. Deletions still succeed: the calendar call fails and is
|
||||
# tolerated. Nothing here reads calendars.
|
||||
CALENDAR_SERVICE_ENABLED=false
|
||||
31
local-instance/env/client.env
vendored
Normal file
31
local-instance/env/client.env
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# schulcloud-client, the legacy Express/Handlebars UI. Still serves "/" and
|
||||
# large parts of the course view, so it is not optional.
|
||||
|
||||
PORT=3100
|
||||
# HOST is what the client puts into redirects, so it must be the browser-facing
|
||||
# origin (the proxy), not this container's own address.
|
||||
HOST=http://localhost:4400
|
||||
API_HOST=http://api:3030/api
|
||||
PUBLIC_BACKEND_URL=http://localhost:4400/api
|
||||
|
||||
FILES_STORAGE__SERVICE_BASE_URL=http://file-storage:4444
|
||||
|
||||
ETHERPAD__PAD_URI=http://localhost:4400/etherpad/p
|
||||
ETHERPAD__PAD_PATH=/etherpad/p
|
||||
ETHERPAD__DOMAIN=localhost
|
||||
FEATURE_ETHERPAD_ENABLED=true
|
||||
|
||||
SESSION_VALKEY__MODE=single
|
||||
SESSION_VALKEY__URI=redis://valkey:6379
|
||||
SESSION_COOKIE_SAME_SITE=lax
|
||||
|
||||
# The proxy terminates plain HTTP locally; without this the client marks the
|
||||
# jwt cookie Secure and the browser silently drops it.
|
||||
COOKIE__SECURE=false
|
||||
COOKIE__SAME_SITE=lax
|
||||
COOKIE__HTTP_ONLY=false
|
||||
|
||||
# Signs the session cookie. Local-only value; the app refuses to start without it.
|
||||
COOKIE_SECRET=local-instance-cookie-secret-not-a-real-secret
|
||||
|
||||
LOG_LEVEL=info
|
||||
11
local-instance/env/etherpad.env
vendored
Normal file
11
local-instance/env/etherpad.env
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Etherpad, the collaborative text editor element on column boards.
|
||||
# Settings taken from docs/topics/etherpad/Local setup.md.
|
||||
|
||||
REQUIRE_SESSION=true
|
||||
PAD_OPTIONS_SHOW_CHAT=true
|
||||
DISABLE_IP_LOGGING=true
|
||||
DEFAULT_PAD_TEXT=Schreib etwas!
|
||||
DB_TYPE=mongodb
|
||||
DB_URL=mongodb://mongo:27017/etherpad
|
||||
AUTHENTICATION_METHOD=apikey
|
||||
TRUST_PROXY=true
|
||||
30
local-instance/env/file-storage.env
vendored
Normal file
30
local-instance/env/file-storage.env
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# file-storage service (repo hpi-schul-cloud/file-storage), the /api/v3/file/* API.
|
||||
|
||||
FILES_STORAGE__SERVICE_BASE_URL=http://file-storage:4444
|
||||
FILE_STORAGE_SERVICE_URL=http://file-storage:4444
|
||||
AUTHORIZATION_API_URL=http://api:3030/api/v3
|
||||
|
||||
FILES_STORAGE_S3_ENDPOINT=http://minio:9000/
|
||||
FILES_STORAGE_S3_BUCKET=schulcloud
|
||||
FILES_STORAGE_S3_REGION=eu-central-1
|
||||
FILES_STORAGE_S3_ACCESS_KEY_ID=miniouser
|
||||
FILES_STORAGE_S3_SECRET_ACCESS_KEY=miniouser
|
||||
|
||||
# Antivirus. Off by default: ClamAV wants ~1.5 GB of RAM for its signature DB.
|
||||
# Turn it on together with the `av` profile — with the scanner absent but the
|
||||
# check enabled, every upload stays stuck in securityCheck.status=pending and
|
||||
# can never be downloaded.
|
||||
ENABLE_FILE_SECURITY_CHECK=false
|
||||
ANTIVIRUS_SERVICE_HOSTNAME=clamav
|
||||
ANTIVIRUS_SERVICE_PORT=3310
|
||||
FILES_STORAGE_USE_STREAM_TO_ANTIVIRUS=false
|
||||
|
||||
PREVIEW_PRODUCER_INCOMING_REQUEST_TIMEOUT=10000
|
||||
|
||||
# Collabora must be reachable by the *browser*, so this one is a host URL.
|
||||
COLLABORA_ONLINE_URL=http://localhost:9980
|
||||
WOPI_URL=http://localhost:4400/api/v3/wopi/files
|
||||
WOPI_POST_MESSAGE_ORIGIN=http://localhost:4400
|
||||
FEATURE_COLUMN_BOARD_COLLABORA_ENABLED=true
|
||||
|
||||
LOGGER_LOG_LEVEL=info
|
||||
18
local-instance/env/h5p.env
vendored
Normal file
18
local-instance/env/h5p.env
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# h5p-server: the h5p-editor app plus the one-shot library-management job.
|
||||
|
||||
H5P_EDITOR__S3_ENDPOINT=http://minio:9000
|
||||
H5P_EDITOR__S3_REGION=eu-central-1
|
||||
H5P_EDITOR__S3_ACCESS_KEY_ID=miniouser
|
||||
H5P_EDITOR__S3_SECRET_ACCESS_KEY=miniouser
|
||||
H5P_EDITOR__S3_BUCKET_CONTENT=h5p-content-bucket
|
||||
H5P_EDITOR__S3_BUCKET_LIBRARIES=h5p-library-bucket
|
||||
H5P_EDITOR__LIBRARIES_S3_ACCESS_KEY_ID=miniouser
|
||||
H5P_EDITOR__LIBRARIES_S3_SECRET_ACCESS_KEY=miniouser
|
||||
|
||||
# A short list keeps the one-off library install to a couple of minutes; the
|
||||
# upstream default installs ~40 libraries and takes far longer.
|
||||
H5P_EDITOR__LIBRARY_LIST=H5P.ArithmeticQuiz,H5P.Chart,H5P.MultiChoice,H5P.Blanks
|
||||
|
||||
API_HOST=http://api:3030/api
|
||||
CORE_INCOMING_REQUEST_TIMEOUT_MS=8000
|
||||
LOGGER_LOG_LEVEL=info
|
||||
5
local-instance/env/jwt.env
vendored
Normal file
5
local-instance/env/jwt.env
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Development RSA keypair, copied verbatim from schulcloud-server/.env.development
|
||||
# (upstream, public, committed in their repo). It exists so every local service
|
||||
# validates the same tokens. NEVER use these keys anywhere reachable from outside.
|
||||
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIJKAIBAAKCAgEA0/oW2sIZWvVt0AEgQ8PS80/udJzfWXu6t2QWjUcQA2THGvDS\nXXMH6YMMY2czyBgf6L7hHV/9p1Trfpe7YgxYhOoGsxhXG1keAYQ4+mdveaUAa3ui\nACdEodsB0OFjVUdgOHCyUIXFfhSsp2p2tmZeFi/bE2v/05kYO+ExgQuzUDbB8bCr\n1sc7gMS/2dC2iE/BVw/I0F14oZkZn0fshojg4qoaLbLVKB7Iw53IXF2878zXp81J\ndnnvHdwVbGWqoII6sHZFQs8ob5S/WGMl4QnBHN98x0KmORUFyTv5kK4cdcC8LJ1H\npoVWNC6js84iF9yFRhYXY2RHqh7BwaZZ4XZym/MetTdQTBDaSvhXe0A3WdahNG+D\nGriehd6doWk98Adb49InaodH64ZRkurxiX61GEtzjMRq9EfGS5R/IfcWyPQbiir6\nymKXfOUtywRjcm3FZzmT7j3c0UHzQVEH0NBfTMj+QKz5NILNP230j0DcjNImDbHH\ncVH1quSb6e0WXjKANTkf4gaTOw7jdQDFw0Ou3aEmwPg+Xk1cwCwSHOOmPSSssZwg\njpzGodPO3vsMGfRYTwcGbzgdQFFj0qTmvgnM5MHtEy8qCyvM4OsAPnE0zQWn48p7\nPVdJm6j0H/1BYgVw1KxecIVk/HryoTOkgS9lhLu8iEIyrpAlWascIK7Uw58CAwEA\nAQKCAgAA0/lC4X83272SEm8N1LX+PVGxIuu8bb9M+BcediiZ2srsUASCWPCu+NQT\nj1OkdHOrdRNsCfPzs2E4HV+eAm5WFpPwHyg38yEq4FlYoQ7OataVlOYNGhoqh7B6\nIGdC7gRyM/5+UgdzdqE2BjRwgfXcIFO6v7FAIlj14utOlb0dkxku2IHTVPPmjN4y\n+5266pTWwjkGl1bhSrfO53kFDYPTXta7Vvd+MKCYIwWlVrhmN2agQS0ISXGlrDZp\nNfx0pA2Wot+iYyzFQs98iOac+mzGsBjMrnX3wx1Cq/lNl2CFFTum8PZWsC6mBYie\nKy/25+WdYHi26q1c/MHE/+FaABxyfa3PCXc4qmA9BHcrxVB3EtvFYxOUrGuI//S9\n7PLswRiPd80amo2NpAg15k03ubK9i8jD1PYjgKmhDayd9fmLSAtUrTdvP1MINBiu\nswEmJRyARMW2DCJc4E6+xDObSpy7zWsVEQWRKVt4g+73/zgOgFpPqdDgz7BTcBa9\niRVw1FrjI4TbRMlJpfD+gcyNYiXy7oJ94oHxDU/m8lwFcyMnRboz8QdjisIGG/Vy\n8U+chaAClGbr2CWTFyRHqXuXd2RIRQ3gU9To0Elpff9Scy8KnARohr5xzcFku9Os\nAyQ+rTXx7vDFoWilLQLQmMo2mNSSjRTvaD2vcb1AD4VeMDlYAQKCAQEA+Au90TYy\nVArIdN5d+xXqD5nYkcfKgR2EvVmrW8H1yAI3MbAmYtA8HpLHQhJSm+SDSnaszLZh\nV/nDmHsPUGs1U0O8RjkHxmljTbTH469CIeGvnR8ODcqH8C5Ds1vfrxYjG9Axih3I\nOp+mJs4HyBsCU6LmPJUCKuYtsxY8s/qhTmXHxDxnkW1niIlBTE4pqhThFTojPWfE\nHR7niK5PpayYsEGRbYceXGcrn7Rl26+FvbQCJ3XrhAwrG9+U18V3KLs87VePfBz3\nfEuej6x35e83z0l0aSqQW5sJmunlmxvWJMQLir16oebpLsgcjtBnhdl/Q/JSbHMC\nCnbuZcnDoIPHCwKCAQEA2sZAH9f4I+gdz0jgyOUMdC8dBMOQN0uVo4YUXKJGOgkc\nQ+TcfE990eTdJcEv+FlWeq1CPbwcIqrQrlhwDypSCjsVWKVL2eaSdpY3cNsKCT5W\nVnoOV6lGpiXqq0xy6UK/hkuTCDk9W4u536qZSLPSFbMjVKfOlexcx1gNZiHTGGLv\nDOSw0JdkS7XA6Whq5kToFoA4uwMK70mWYGv+FV87kvF080TeGs6YOIuSXM6++hwY\ndhBEoqXYfiVwCeBT5VH+fnAh/dBufUd68oNUCcfKJ1nkOlggyHwU1aJjkeO6bA2k\nPuxjtTd9pCzpCgS2nmCj0E24qKf9GPyef+SndsjCPQKCAQEAwCTgSoMwI1gjBh0H\nMiw8nw8u62aX4MLMA53FlxO938yPkucAJUVnfMt4nR7ybR5r8a/SldWlvG+W67RQ\nHZyetzxeSQt+kV0r9pLW0PH/SZ242v6mdVpxSUWdXgAKW2fLlI0HAxWk+HyZSbAJ\n6SG7AKzMqxtGjZK2zeao6UZ50/AV+lZMaCQWsnaYZZKaxczcuwPJLpUGHwTEmGVm\n/1CfCtIP5IdppmypJ1KoILBr6pLZpFW9NhHzBumANFEbyCqavMQ6Owt5TwiI8ITK\ncAyJ8AHXsmutXbjQjPcozKmYjexrgHLc3zOvaHTNYnff6Zic9DZvUOEaMJ8Gd0T/\nTIUoFwKCAQBaiA+hHc4hjbxIOvBKMf6lVZm8jvDu8OhLcwCaFMza10pLDjnvdzWp\n1ftt1DP1oYKX4Xq38U/zSJxyiUZWAD1S3oBG3qA026VgTWlD2mCc0p8HyhqFTBdg\nSfCCUnB69pQrDrsZfBZX+8o/NGmaHE+jiy3jqk1i3RzHoThqOzUPsmEaBMjmiL+I\nVP4vmHYkM/+W0BipyuiLfPgtjoLmdTJB7Ilo4ebHURbMz3UR0rxU46t7r9+3LsoX\n6YYjkCEnlHar+9sVHVubnCjUkmQEaBjPj/NR8YYfcLlubnSluoc6j6qYH1pjc0Ma\n3TrSWoD3qSYg3Qi9QkcKP/+XDRf/n7RBAoIBAEdAxaD/vUW7DwGPIAbziMtkx03R\nCc7Tdp+v8XURUu5HrAxXdGK1J8ufgevFhJ6jXre/25BV9RVGAUzAK95xEkZh/ulB\nuFtxUN2CRh92EWGiC8FYtMkJEFnkjAxBjucFOWkRHjzJMF7+PuNeQSb4TEiGMEZg\nt1VWdHgL+FpNuZsKzuZ9jwfALj27LAkkJLjpH9DXDo6e7aJlCqbe8ili1gLo80FZ\np65W4wIRQSChoMcOHgZCbOBebUSW0zXLvccXoq+BGlt+qLM830Y0UFolbckHrF1O\nCTSPG6IaRisx3D2hNNrZIcyZaIwZeHhvj7fib/5hMRerXzSTH1QMXPc2bH4=\n-----END RSA PRIVATE KEY-----\n"
|
||||
JWT_PUBLIC_KEY="-----BEGIN RSA PUBLIC KEY-----\nMIICCgKCAgEA0/oW2sIZWvVt0AEgQ8PS80/udJzfWXu6t2QWjUcQA2THGvDSXXMH\n6YMMY2czyBgf6L7hHV/9p1Trfpe7YgxYhOoGsxhXG1keAYQ4+mdveaUAa3uiACdE\nodsB0OFjVUdgOHCyUIXFfhSsp2p2tmZeFi/bE2v/05kYO+ExgQuzUDbB8bCr1sc7\ngMS/2dC2iE/BVw/I0F14oZkZn0fshojg4qoaLbLVKB7Iw53IXF2878zXp81Jdnnv\nHdwVbGWqoII6sHZFQs8ob5S/WGMl4QnBHN98x0KmORUFyTv5kK4cdcC8LJ1HpoVW\nNC6js84iF9yFRhYXY2RHqh7BwaZZ4XZym/MetTdQTBDaSvhXe0A3WdahNG+DGrie\nhd6doWk98Adb49InaodH64ZRkurxiX61GEtzjMRq9EfGS5R/IfcWyPQbiir6ymKX\nfOUtywRjcm3FZzmT7j3c0UHzQVEH0NBfTMj+QKz5NILNP230j0DcjNImDbHHcVH1\nquSb6e0WXjKANTkf4gaTOw7jdQDFw0Ou3aEmwPg+Xk1cwCwSHOOmPSSssZwgjpzG\nodPO3vsMGfRYTwcGbzgdQFFj0qTmvgnM5MHtEy8qCyvM4OsAPnE0zQWn48p7PVdJ\nm6j0H/1BYgVw1KxecIVk/HryoTOkgS9lhLu8iEIyrpAlWascIK7Uw58CAwEAAQ==\n-----END RSA PUBLIC KEY-----\n"
|
||||
10
local-instance/env/nuxt.env
vendored
Normal file
10
local-instance/env/nuxt.env
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# schulcloud-frontend (nuxt-client). The image is an nginx serving the built
|
||||
# SPA; these values are substituted into its config template at container start
|
||||
# (see nuxt-client/config/docker/nginx.conf.template).
|
||||
|
||||
PUBLIC_BACKEND_URL=http://localhost:4400/api
|
||||
LEGACY_CLIENT_URL=http://client:3100
|
||||
COLLABORA_OFFICE_URL=http://localhost:9980
|
||||
LICENSE_SUMMARY_URL_FOR_CSP=http://localhost:4400
|
||||
H5P_SCRIPT_SRC_URLS=http://localhost:4400
|
||||
H5P_IMG_SRC_URLS=http://localhost:4400
|
||||
46
local-instance/env/shared.env
vendored
Normal file
46
local-instance/env/shared.env
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
# Shared by every Schulcloud app in the stack.
|
||||
# Everything in this directory is local-only and deliberately non-secret.
|
||||
|
||||
NODE_ENV=production
|
||||
NO_COLOR=true
|
||||
TZ=Europe/Berlin
|
||||
|
||||
# --- identity of this instance -------------------------------------------
|
||||
# Mirrors dof_app_deploy/ansible/group_vars/thr/instance_cfg.yml so the local
|
||||
# instance looks and behaves like schulcloud-thueringen.de.
|
||||
SC_THEME=thr
|
||||
SC_SHORTNAME=thr
|
||||
SC_TITLE=Thüringer Schulcloud
|
||||
SC_PRODUCTNAME=Thüringer Schulcloud
|
||||
SC_NAV_TITLE=Thüringer Schulcloud
|
||||
SC_CONTACT_EMAIL=schulcloud-support@thillm.de
|
||||
ACCESSIBILITY_REPORT_EMAIL=institut@thillm.de
|
||||
|
||||
# The single origin the browser talks to (the nginx in ./proxy).
|
||||
SC_DOMAIN=localhost:4400
|
||||
HOST=http://localhost:4400
|
||||
# The API stamps SC_DOMAIN into every JWT as both issuer and audience. The
|
||||
# satellite services (file-storage, h5p) validate iss/aud against JWT_DOMAIN,
|
||||
# which defaults to a bare "localhost" — so without this they reject every
|
||||
# token the API issued and the homework page's file lookups 401. Keep the two
|
||||
# in lockstep.
|
||||
JWT_DOMAIN=localhost:4400
|
||||
PUBLIC_BACKEND_URL=http://localhost:4400/api
|
||||
CTL_TOOLS_BACKEND_URL=http://localhost:4400/api
|
||||
|
||||
# --- infrastructure ------------------------------------------------------
|
||||
DB_URL=mongodb://mongo:27017/schulcloud
|
||||
DB_ENSURE_INDEXES=true
|
||||
RABBITMQ_URI=amqp://guest:guest@rabbitmq:5672
|
||||
|
||||
# The JWT whitelist. `single` reproduces production: every authenticated
|
||||
# request re-sets a Valkey key with a JWT_TIMEOUT_SECONDS TTL, and losing that
|
||||
# key logs the session out. `in-memory` would hide that behaviour entirely,
|
||||
# which is exactly the behaviour this instance exists to test.
|
||||
SESSION_VALKEY__MODE=single
|
||||
SESSION_VALKEY__URI=redis://valkey:6379
|
||||
JWT_TIMEOUT_SECONDS=7200
|
||||
JWT_SHOW_TIMEOUT_WARNING_SECONDS=3600
|
||||
|
||||
AES_KEY=randomStringWithAtLeast16Chars;
|
||||
S3_KEY=abcdefghijklmnop
|
||||
17
local-instance/env/tldraw.env
vendored
Normal file
17
local-instance/env/tldraw.env
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# tldraw-server: the whiteboard element on column boards. Server + worker.
|
||||
|
||||
REDIS_URL=redis://valkey:6379
|
||||
AUTHORIZATION_API_HOST=http://api:3030
|
||||
|
||||
S3_ACCESS_KEY=miniouser
|
||||
S3_SECRET_KEY=miniouser
|
||||
S3_BUCKET=ydocs
|
||||
S3_ENDPOINT=minio
|
||||
S3_PORT=9000
|
||||
S3_SSL=false
|
||||
|
||||
FEATURE_TLDRAW_ENABLED=true
|
||||
TLDRAW_WEBSOCKET_URL=ws://localhost:4400/tldraw-server
|
||||
X_API_ALLOWED_KEYS=tldraw-admin-key
|
||||
NOT_AUTHENTICATED_REDIRECT_URL=http://localhost:4400/login
|
||||
LOGGER_LOG_LEVEL=info
|
||||
1
local-instance/etherpad/APIKEY.txt
Normal file
1
local-instance/etherpad/APIKEY.txt
Normal file
@@ -0,0 +1 @@
|
||||
381d67e6347d235ac9446da3ea10a82efd6f8ae09fa2e90efeda80f82feeb4fd
|
||||
63
local-instance/file-preview/policy.xml
Normal file
63
local-instance/file-preview/policy.xml
Normal file
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policymap [
|
||||
<!ELEMENT policymap (policy)*>
|
||||
<!ATTLIST policymap xmlns CDATA #FIXED "">
|
||||
<!ELEMENT policy EMPTY>
|
||||
<!ATTLIST policy xmlns CDATA #FIXED "">
|
||||
<!ATTLIST policy domain NMTOKEN #REQUIRED>
|
||||
<!ATTLIST policy name NMTOKEN #IMPLIED>
|
||||
<!ATTLIST policy pattern CDATA #IMPLIED>
|
||||
<!ATTLIST policy rights NMTOKEN #IMPLIED>
|
||||
<!ATTLIST policy stealth NMTOKEN #IMPLIED>
|
||||
<!ATTLIST policy value CDATA #IMPLIED>
|
||||
]>
|
||||
<policymap>
|
||||
<!-- Resource limits to prevent OOM based on 4000 MB memory from AMQP_FILE_PREVIEW_MEMORY_LIMITS used in
|
||||
https://github.com/hpi-schul-cloud/file-storage/blob/main/ansible/roles/file-storage/templates/preview-generator-deployment.yml.j2#L64-L68 -->
|
||||
<policy domain="resource" name="memory" value="3.5GiB"/>
|
||||
<policy domain="resource" name="map" value="3.5GiB"/>
|
||||
<policy domain="resource" name="area" value="1GB"/>
|
||||
<policy domain="resource" name="disk" value="2GiB"/>
|
||||
<policy domain="resource" name="width" value="16KP"/>
|
||||
<policy domain="resource" name="height" value="16KP"/>
|
||||
<policy domain="resource" name="time" value="60"/>
|
||||
<policy domain="resource" name="list-length" value="1024"/>
|
||||
<policy domain="resource" name="thread" value="4"/>
|
||||
|
||||
<!-- Security: Disable dangerous format handlers -->
|
||||
<policy domain="coder" rights="none" pattern="EPHEMERAL"/>
|
||||
<policy domain="coder" rights="none" pattern="URL"/>
|
||||
<policy domain="coder" rights="none" pattern="HTTPS"/>
|
||||
<policy domain="coder" rights="none" pattern="MVG"/>
|
||||
<policy domain="coder" rights="none" pattern="MSL"/>
|
||||
<policy domain="coder" rights="none" pattern="PS"/>
|
||||
<policy domain="coder" rights="none" pattern="EPS"/>
|
||||
<policy domain="coder" rights="none" pattern="LABEL"/>
|
||||
<policy domain="coder" rights="none" pattern="CAPTION"/>
|
||||
<policy domain="coder" rights="none" pattern="TEXT"/>
|
||||
<policy domain="coder" rights="none" pattern="DOT"/>
|
||||
<policy domain="coder" rights="none" pattern="PLT"/>
|
||||
<policy domain="coder" rights="none" pattern="HPGL"/>
|
||||
<policy domain="coder" rights="none" pattern="PCL"/>
|
||||
<policy domain="coder" rights="none" pattern="XPS"/>
|
||||
<policy domain="coder" rights="none" pattern="FIG"/>
|
||||
|
||||
<!-- Input formats.
|
||||
Upstream ships these as rights="read", which ImageMagick 7.1.2 — the
|
||||
version in this image — rejects at IsCoderAuthorized: every preview
|
||||
fails with "attempt to perform an operation not authorized by the
|
||||
security policy `PNG'" (or `PDF'), the record is flagged
|
||||
previewGenerationFailed, and /api/v3/file/preview answers 404
|
||||
PREVIEW_NOT_POSSIBLE even while the file record still reports
|
||||
previewStatus: preview_possible. Granting write as well is what makes
|
||||
the preview profile do anything at all here. -->
|
||||
<policy domain="coder" rights="read|write" pattern="JPEG"/>
|
||||
<policy domain="coder" rights="read|write" pattern="PNG"/>
|
||||
<policy domain="coder" rights="read|write" pattern="TIFF"/>
|
||||
<policy domain="coder" rights="read|write" pattern="HEIC"/>
|
||||
<policy domain="coder" rights="read|write" pattern="PDF"/>
|
||||
<policy domain="coder" rights="read|write" pattern="SVG"/>
|
||||
|
||||
<!-- Output format: READ + WRITE -->
|
||||
<policy domain="coder" rights="read|write" pattern="WEBP"/>
|
||||
</policymap>
|
||||
596
local-instance/proxy/nginx.conf
Normal file
596
local-instance/proxy/nginx.conf
Normal file
@@ -0,0 +1,596 @@
|
||||
# GENERATED by scripts/gen-proxy-conf.py — do not edit by hand.
|
||||
#
|
||||
# One origin in front of the whole stack, the way the real instance is fronted
|
||||
# by its Kubernetes ingress. The path split between the legacy client and the
|
||||
# new SPA is not cosmetic: get it wrong and you are testing a different
|
||||
# application from the one the students use.
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 4400;
|
||||
server_name localhost;
|
||||
|
||||
# Docker's embedded DNS. Every proxy_pass below goes through a variable so
|
||||
# that names resolve per request rather than at startup — otherwise this
|
||||
# container refuses to boot whenever an optional profile (tools, av) is
|
||||
# down, which is the normal case.
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
# Course files and H5P uploads are large; the ingress allows the same.
|
||||
client_max_body_size 2600m;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
|
||||
# The legacy client sets several large cookies at once (jwt plus the
|
||||
# Etherpad session it requests on every topic page), which overflows
|
||||
# nginx's default 4k header buffer: the upstream answers 200 and the proxy
|
||||
# still returns 502 with "upstream sent too big header". Topic pages are
|
||||
# the visible casualty, and because the MCP server's topic-page scrape
|
||||
# degrades to an empty list by design, the ids it recovers just silently
|
||||
# vanish rather than erroring.
|
||||
proxy_buffer_size 32k;
|
||||
proxy_buffers 8 32k;
|
||||
proxy_busy_buffers_size 64k;
|
||||
|
||||
# version-aggregator-svc upstream; /serverversion and /nuxtversion are the
|
||||
# real per-app endpoints and are routed below.
|
||||
location = /version {
|
||||
default_type application/json;
|
||||
return 200 '{"local-instance":true,"see":["/serverversion","/nuxtversion"]}';
|
||||
}
|
||||
|
||||
# --- service-owned ingresses and websockets ---
|
||||
|
||||
location /api/v3/file/ {
|
||||
set $up_api_v3_file file-storage:4444;
|
||||
proxy_pass http://$up_api_v3_file;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /api/v3/wopi/ {
|
||||
set $up_api_v3_wopi file-storage:4444;
|
||||
proxy_pass http://$up_api_v3_wopi;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /api/v3/h5p-editor/h5pstatics/ {
|
||||
set $up_api_v3_h5p_editor_h5pstatics h5p-staticfiles:8080;
|
||||
rewrite ^/api/v3/h5p-editor/h5pstatics/(.*)$ /h5pstatics/$1 break;
|
||||
proxy_pass http://$up_api_v3_h5p_editor_h5pstatics;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /api/v3/h5p-editor/ {
|
||||
set $up_api_v3_h5p_editor h5p-editor:4448;
|
||||
proxy_pass http://$up_api_v3_h5p_editor;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /api/v3/ {
|
||||
set $up_api_v3 api:3030;
|
||||
proxy_pass http://$up_api_v3;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /admin/api/v1 {
|
||||
set $up_admin_api_v1 admin-api:4030;
|
||||
proxy_pass http://$up_admin_api_v1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /board-collaboration {
|
||||
set $up_board_collaboration board-collaboration:4450;
|
||||
proxy_pass http://$up_board_collaboration;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
|
||||
location /tldraw-server {
|
||||
set $up_tldraw_server tldraw-server:3345;
|
||||
proxy_pass http://$up_tldraw_server;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
|
||||
location /api/tldraw {
|
||||
set $up_api_tldraw tldraw-server:3345;
|
||||
proxy_pass http://$up_api_tldraw;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# --- from dof_app_deploy x_ingress.yml ---
|
||||
|
||||
# default
|
||||
location / {
|
||||
set $up_root client:3100;
|
||||
proxy_pass http://$up_root;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# themes
|
||||
location /favicon.png {
|
||||
set $up_favicon_png nuxt:4000;
|
||||
proxy_pass http://$up_favicon_png;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# bbb_presentation_pdf
|
||||
location /bbb-presentation.pdf {
|
||||
set $up_bbb_presentation_pdf nuxt:4000;
|
||||
proxy_pass http://$up_bbb_presentation_pdf;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# runtime
|
||||
location /runtime.config.json {
|
||||
set $up_runtime_config_json nuxt:4000;
|
||||
proxy_pass http://$up_runtime_config_json;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# metrics
|
||||
location /metrics {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# login
|
||||
location /login {
|
||||
set $up_login client:3100;
|
||||
proxy_pass http://$up_login;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# error
|
||||
location /error {
|
||||
set $up_error nuxt:4000;
|
||||
proxy_pass http://$up_error;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# imprint
|
||||
location /imprint {
|
||||
set $up_imprint nuxt:4000;
|
||||
proxy_pass http://$up_imprint;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# mint-ec
|
||||
location /mint-ec {
|
||||
set $up_mint_ec nuxt:4000;
|
||||
proxy_pass http://$up_mint_ec;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# news
|
||||
location /news {
|
||||
set $up_news nuxt:4000;
|
||||
proxy_pass http://$up_news;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# tasks
|
||||
location /tasks {
|
||||
set $up_tasks nuxt:4000;
|
||||
proxy_pass http://$up_tasks;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# nuxtversion
|
||||
location /nuxtversion {
|
||||
set $up_nuxtversion nuxt:4000;
|
||||
proxy_pass http://$up_nuxtversion;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# content
|
||||
location /content {
|
||||
set $up_content nuxt:4000;
|
||||
proxy_pass http://$up_content;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# _nuxt
|
||||
location /_nuxt {
|
||||
set $up__nuxt nuxt:4000;
|
||||
proxy_pass http://$up__nuxt;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# administration_ldap
|
||||
location /administration/ldap {
|
||||
set $up_administration_ldap nuxt:4000;
|
||||
proxy_pass http://$up_administration_ldap;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# administration_migration
|
||||
location /administration/migration {
|
||||
set $up_administration_migration nuxt:4000;
|
||||
proxy_pass http://$up_administration_migration;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# administration_school-settings
|
||||
location /administration/school-settings {
|
||||
set $up_administration_school_settings nuxt:4000;
|
||||
proxy_pass http://$up_administration_school_settings;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# administration_students
|
||||
location /administration/students {
|
||||
set $up_administration_students nuxt:4000;
|
||||
proxy_pass http://$up_administration_students;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# administration_teachers
|
||||
location /administration/teachers {
|
||||
set $up_administration_teachers nuxt:4000;
|
||||
proxy_pass http://$up_administration_teachers;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# administration_rooms
|
||||
location /administration/rooms/manage {
|
||||
set $up_administration_rooms_manage nuxt:4000;
|
||||
proxy_pass http://$up_administration_rooms_manage;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# administration_groups_classes
|
||||
location /administration/groups/classes {
|
||||
set $up_administration_groups_classes nuxt:4000;
|
||||
proxy_pass http://$up_administration_groups_classes;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# boards
|
||||
location /boards {
|
||||
set $up_boards nuxt:4000;
|
||||
proxy_pass http://$up_boards;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# administration_rooms_new
|
||||
location /administration/rooms/new {
|
||||
set $up_administration_rooms_new nuxt:4000;
|
||||
proxy_pass http://$up_administration_rooms_new;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# rooms-overview
|
||||
location /rooms-overview {
|
||||
set $up_rooms_overview nuxt:4000;
|
||||
proxy_pass http://$up_rooms_overview;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# dashboard
|
||||
location /dashboard {
|
||||
set $up_dashboard nuxt:4000;
|
||||
proxy_pass http://$up_dashboard;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# rooms
|
||||
location /rooms {
|
||||
set $up_rooms nuxt:4000;
|
||||
proxy_pass http://$up_rooms;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# folder
|
||||
location /folder {
|
||||
set $up_folder nuxt:4000;
|
||||
proxy_pass http://$up_folder;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# collabora
|
||||
location /collabora {
|
||||
set $up_collabora nuxt:4000;
|
||||
proxy_pass http://$up_collabora;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# h5p-player
|
||||
location /h5p/player {
|
||||
set $up_h5p_player nuxt:4000;
|
||||
proxy_pass http://$up_h5p_player;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# h5p-editor
|
||||
location /h5p/editor {
|
||||
set $up_h5p_editor nuxt:4000;
|
||||
proxy_pass http://$up_h5p_editor;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# migration
|
||||
location /migration {
|
||||
set $up_migration nuxt:4000;
|
||||
proxy_pass http://$up_migration;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# tools_context
|
||||
location /tools/context {
|
||||
set $up_tools_context nuxt:4000;
|
||||
proxy_pass http://$up_tools_context;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# media_shelf
|
||||
location /media-shelf {
|
||||
set $up_media_shelf nuxt:4000;
|
||||
proxy_pass http://$up_media_shelf;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# licenses
|
||||
location /licenses {
|
||||
set $up_licenses nuxt:4000;
|
||||
proxy_pass http://$up_licenses;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# registration-external-members
|
||||
location /registration-external-members {
|
||||
set $up_registration_external_members nuxt:4000;
|
||||
proxy_pass http://$up_registration_external_members;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# security
|
||||
location /system/security {
|
||||
set $up_system_security nuxt:4000;
|
||||
proxy_pass http://$up_system_security;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# api
|
||||
location /api {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# api_v1_roster
|
||||
location /api/v1/roster {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# api_v1_consentVersions
|
||||
location /api/v1/consentVersions {
|
||||
set $up_api_v1_consentVersions api:3030;
|
||||
proxy_pass http://$up_api_v1_consentVersions;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# api_v1_ldap-config
|
||||
location /api/v1/ldap-config {
|
||||
set $up_api_v1_ldap_config api:3030;
|
||||
proxy_pass http://$up_api_v1_ldap_config;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# api_version
|
||||
location /serverversion {
|
||||
set $up_serverversion api:3030;
|
||||
proxy_pass http://$up_serverversion;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# api_v1_courses
|
||||
location /api/v1/courses {
|
||||
set $up_api_v1_courses api:3030;
|
||||
proxy_pass http://$up_api_v1_courses;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# api_v1_users
|
||||
location /api/v1/users {
|
||||
set $up_api_v1_users api:3030;
|
||||
proxy_pass http://$up_api_v1_users;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# api_v1_classes
|
||||
location /api/v1/classes {
|
||||
set $up_api_v1_classes api:3030;
|
||||
proxy_pass http://$up_api_v1_classes;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ^~ /etherpad/admin { return 404; }
|
||||
location ^~ /etherpad/stats { return 404; }
|
||||
|
||||
location /etherpad/socket.io {
|
||||
set $up_etherpad etherpad:9001;
|
||||
rewrite /etherpad/socket.io/(.*) /socket.io/$1 break;
|
||||
proxy_pass http://$up_etherpad;
|
||||
proxy_redirect / /etherpad/;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_buffering off;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
|
||||
location /etherpad {
|
||||
set $up_etherpad etherpad:9001;
|
||||
rewrite ^/etherpad$ /etherpad/ permanent;
|
||||
rewrite /etherpad/(.*) /$1 break;
|
||||
proxy_pass http://$up_etherpad;
|
||||
proxy_pass_header Server;
|
||||
proxy_redirect / /etherpad/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# version_aggregator
|
||||
location /version {
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
201
local-instance/scripts/gen-proxy-conf.py
Normal file
201
local-instance/scripts/gen-proxy-conf.py
Normal file
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate proxy/nginx.conf from the real deployment's ingress table.
|
||||
|
||||
The live instance is a Kubernetes ingress that splits one origin across the
|
||||
legacy client, the new SPA and several APIs. Which path goes where is not
|
||||
documented in prose — it is the table in
|
||||
dof_app_deploy/ansible/group_vars/all/x_ingress.yml plus a per-path ingress in
|
||||
each service repo. Transcribing 46 rules by hand invites exactly the drift that
|
||||
would make this instance lie about the real one, so we generate them.
|
||||
|
||||
Usage: python3 scripts/gen-proxy-conf.py > proxy/nginx.conf
|
||||
Needs the upstream clones in ../vendor (see README.md).
|
||||
"""
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.exit('needs PyYAML: pip install pyyaml')
|
||||
|
||||
VENDOR = pathlib.Path(__file__).resolve().parents[2] / 'vendor'
|
||||
INGRESS = VENDOR / 'dof_app_deploy/ansible/group_vars/all/x_ingress.yml'
|
||||
|
||||
# Kubernetes service name -> compose upstream. None means the real deployment
|
||||
# deliberately 404s that path.
|
||||
UPSTREAM = {
|
||||
'client-svc': 'client:3100',
|
||||
'nuxtclient-svc': 'nuxt:4000',
|
||||
'api-svc': 'api:3030',
|
||||
'default-backend-404-svc': None,
|
||||
'version-aggregator-svc': None, # replaced by our own /version below
|
||||
None: None,
|
||||
}
|
||||
|
||||
# Routes that live in the individual service repos' own ingress templates
|
||||
# rather than the shared table, plus the two websocket endpoints.
|
||||
# (path, upstream, websocket, rewrite-or-None)
|
||||
EXTRA = [
|
||||
('/api/v3/file/', 'file-storage:4444', False, None),
|
||||
('/api/v3/wopi/', 'file-storage:4444', False, None),
|
||||
('/api/v3/h5p-editor/h5pstatics/', 'h5p-staticfiles:8080', False,
|
||||
'^/api/v3/h5p-editor/h5pstatics/(.*)$ /h5pstatics/$1'),
|
||||
('/api/v3/h5p-editor/', 'h5p-editor:4448', False, None),
|
||||
('/api/v3/', 'api:3030', False, None),
|
||||
('/admin/api/v1', 'admin-api:4030', False, None),
|
||||
('/board-collaboration', 'board-collaboration:4450', True, None),
|
||||
('/tldraw-server', 'tldraw-server:3345', True, None),
|
||||
('/api/tldraw', 'tldraw-server:3345', False, None),
|
||||
]
|
||||
|
||||
PREAMBLE = '''# GENERATED by scripts/gen-proxy-conf.py — do not edit by hand.
|
||||
#
|
||||
# One origin in front of the whole stack, the way the real instance is fronted
|
||||
# by its Kubernetes ingress. The path split between the legacy client and the
|
||||
# new SPA is not cosmetic: get it wrong and you are testing a different
|
||||
# application from the one the students use.
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 4400;
|
||||
server_name localhost;
|
||||
|
||||
# Docker's embedded DNS. Every proxy_pass below goes through a variable so
|
||||
# that names resolve per request rather than at startup — otherwise this
|
||||
# container refuses to boot whenever an optional profile (tools, av) is
|
||||
# down, which is the normal case.
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
# Course files and H5P uploads are large; the ingress allows the same.
|
||||
client_max_body_size 2600m;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
|
||||
# The legacy client sets several large cookies at once (jwt plus the
|
||||
# Etherpad session it requests on every topic page), which overflows
|
||||
# nginx's default 4k header buffer: the upstream answers 200 and the proxy
|
||||
# still returns 502 with "upstream sent too big header". Topic pages are
|
||||
# the visible casualty, and because the MCP server's topic-page scrape
|
||||
# degrades to an empty list by design, the ids it recovers just silently
|
||||
# vanish rather than erroring.
|
||||
proxy_buffer_size 32k;
|
||||
proxy_buffers 8 32k;
|
||||
proxy_busy_buffers_size 64k;
|
||||
|
||||
# version-aggregator-svc upstream; /serverversion and /nuxtversion are the
|
||||
# real per-app endpoints and are routed below.
|
||||
location = /version {
|
||||
default_type application/json;
|
||||
return 200 '{"local-instance":true,"see":["/serverversion","/nuxtversion"]}';
|
||||
}
|
||||
'''
|
||||
|
||||
COMMON = ''' proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
'''
|
||||
|
||||
WS = ''' proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
'''
|
||||
|
||||
GONE = '''
|
||||
location {path} {{
|
||||
return 404;
|
||||
}}
|
||||
'''
|
||||
|
||||
# Etherpad is mounted under a prefix it knows nothing about, so the deployment
|
||||
# runs a dedicated nginx in front of it that rewrites the prefix away and
|
||||
# proxies socket.io separately. Copied from
|
||||
# dof_app_deploy/ansible/roles/dof_etherpad/templates/nginx-configmap-files.yml.j2
|
||||
# — a plain proxy_pass gets you a pad that loads and then never syncs.
|
||||
ETHERPAD = '''
|
||||
location ^~ /etherpad/admin { return 404; }
|
||||
location ^~ /etherpad/stats { return 404; }
|
||||
|
||||
location /etherpad/socket.io {
|
||||
set $up_etherpad etherpad:9001;
|
||||
rewrite /etherpad/socket.io/(.*) /socket.io/$1 break;
|
||||
proxy_pass http://$up_etherpad;
|
||||
proxy_redirect / /etherpad/;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_buffering off;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
|
||||
location /etherpad {
|
||||
set $up_etherpad etherpad:9001;
|
||||
rewrite ^/etherpad$ /etherpad/ permanent;
|
||||
rewrite /etherpad/(.*) /$1 break;
|
||||
proxy_pass http://$up_etherpad;
|
||||
proxy_pass_header Server;
|
||||
proxy_redirect / /etherpad/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_buffering off;
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def block(path: str, upstream: str, ws: bool, rewrite: str | None, var: str) -> str:
|
||||
out = [f'\n\tlocation {path} {{\n', f'\t\tset ${var} {upstream};\n']
|
||||
if rewrite:
|
||||
out.append(f'\t\trewrite {rewrite} break;\n')
|
||||
out.append(f'\t\tproxy_pass http://${var};\n')
|
||||
out.append(COMMON)
|
||||
if ws:
|
||||
out.append(WS)
|
||||
out.append('\t}\n')
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
def varname(path: str) -> str:
|
||||
safe = ''.join(c if c.isalnum() else '_' for c in path.strip('/')) or 'root'
|
||||
return f'up_{safe}'
|
||||
|
||||
|
||||
def main() -> None:
|
||||
table = yaml.safe_load(INGRESS.read_text())['default_ingress']
|
||||
|
||||
seen: set[str] = set()
|
||||
out = [PREAMBLE]
|
||||
|
||||
out.append('\n\t# --- service-owned ingresses and websockets ---\n')
|
||||
for path, upstream, ws, rewrite in EXTRA:
|
||||
seen.add(path)
|
||||
out.append(block(path, upstream, ws, rewrite, varname(path)))
|
||||
|
||||
out.append('\n\t# --- from dof_app_deploy x_ingress.yml ---\n')
|
||||
for name, entry in table.items():
|
||||
path = entry.get('path')
|
||||
if path is None or path in seen:
|
||||
continue
|
||||
seen.add(path)
|
||||
if path == '/etherpad':
|
||||
out.append(ETHERPAD)
|
||||
continue
|
||||
upstream = UPSTREAM.get(entry.get('serviceName'), 'MISSING')
|
||||
if upstream == 'MISSING':
|
||||
sys.exit(f'unknown serviceName for {name}: {entry.get("serviceName")}')
|
||||
out.append(f'\n\t# {name}')
|
||||
out.append(GONE.format(path=path) if upstream is None
|
||||
else block(path, upstream, False, None, varname(path)))
|
||||
|
||||
out.append('}\n')
|
||||
sys.stdout.write(''.join(out))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
51
local-instance/scripts/mcp-env.sh
Executable file
51
local-instance/scripts/mcp-env.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Mint a session on the local instance and print the environment the MCP server
|
||||
# and CLI expect, so they can be pointed at it instead of the live Schulcloud.
|
||||
#
|
||||
# eval "$(./scripts/mcp-env.sh)" # as the demo student
|
||||
# eval "$(./scripts/mcp-env.sh klara.fall@schul-cloud.org Schulcloud1\!)"
|
||||
#
|
||||
# Nothing is written to the repo: the output contains a live session token, and
|
||||
# a throwaway instance is still no reason to start committing those.
|
||||
set -euo pipefail
|
||||
|
||||
# `localhost`, not `127.0.0.1`: it must match SC_DOMAIN, because urls the server
|
||||
# hands back (Etherpad pads, for one) are built from it, and the client refuses
|
||||
# to follow a url onto a different host rather than leak a session cookie there.
|
||||
URL=${LOCAL_SC_URL:-http://localhost:4400}
|
||||
USER=${1:-demo-schueler@schul-cloud.org}
|
||||
PASS=${2:-schulcloud}
|
||||
|
||||
token=$(curl -fsS -X POST "$URL/api/v3/authentication/local" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(printf '{"username":%s,"password":%s}' \
|
||||
"$(printf '%s' "$USER" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \
|
||||
"$(printf '%s' "$PASS" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')")" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["accessToken"])')
|
||||
|
||||
# The index and mirror are pinned too, not just the instance. The repo's .env
|
||||
# normally points at the live account, and process env beats --env-file, so
|
||||
# without these a local smoke run would crawl this throwaway instance straight
|
||||
# into the live index — and a per-course refresh carries everything outside its
|
||||
# scope forward, so the fixtures would outlive the run. Fixtures in real data
|
||||
# is exactly the accident the store tests' "test" guard exists to prevent.
|
||||
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
|
||||
cat <<ENV
|
||||
export TSC_URL=$URL
|
||||
export TSC_JWT_COOKIE=$token
|
||||
export MCP_AUTH_TOKEN=local-instance-token
|
||||
export DATABASE_URL=postgresql://schulcloud:schulcloud@127.0.0.1:55432/schulcloud_local
|
||||
export MIRROR_DIR=$ROOT/tmp/mirror-local
|
||||
# Notes are pinned for the same reason as the mirror: they are the one thing
|
||||
# this server writes, and a local run has no business writing into the real ones.
|
||||
export NOTES_DIR=$ROOT/tmp/notes-local
|
||||
export INDEX_PERSONAL_FILES=true
|
||||
# WebUntis off for a local run: this instance has no timetable, and the key in
|
||||
# the repo's .env belongs to the real school — a fixture run has no business
|
||||
# talking to it, even read-only.
|
||||
export UNTIS_SERVER=
|
||||
export UNTIS_SCHOOL=
|
||||
export UNTIS_USER=
|
||||
export UNTIS_SECRET=
|
||||
ENV
|
||||
26
local-instance/scripts/minio-init.sh
Executable file
26
local-instance/scripts/minio-init.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# Creates the buckets each service expects. MinIO does not create them on
|
||||
# demand: file-storage reports a generic 500 on upload if its bucket is
|
||||
# missing, and the h5p library job fails halfway through.
|
||||
set -eu
|
||||
|
||||
mc alias set local http://minio:9000 miniouser miniouser
|
||||
|
||||
for bucket in \
|
||||
schulcloud ` # files-storage (the /api/v3/file API)` \
|
||||
h5p-content-bucket ` # h5p-editor content` \
|
||||
h5p-library-bucket ` # h5p content types` \
|
||||
ydocs ` # tldraw whiteboard documents` \
|
||||
fwu-content ` # FWU media, unused but cheap to create` \
|
||||
bucket-5f2987e020834114b8efd6f6 # legacy file manager, demo school (see below)
|
||||
do
|
||||
mc mb --ignore-existing "local/$bucket"
|
||||
done
|
||||
|
||||
# The legacy file manager keeps one bucket per school, "bucket-<schoolId>", and
|
||||
# creates it on first upload — then calls PutBucketCors, which MinIO does not
|
||||
# implement, so the first upload fails with "A header you provided implies
|
||||
# functionality that is not implemented". A bucket that already exists skips
|
||||
# both calls. 5f2987e020834114b8efd6f6 is the demo school's fixed seed id.
|
||||
|
||||
mc ls local
|
||||
109
local-instance/scripts/seed.sh
Executable file
109
local-instance/scripts/seed.sh
Executable file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
# Seed the local instance with the upstream demo school.
|
||||
#
|
||||
# This is what the real deployment's init job does (dof_app_deploy
|
||||
# .../schulcloud-server-init/templates/configmap_file_init.yml.j2): it asks the
|
||||
# management app to load backup/setup/*.json, which ships inside the server
|
||||
# image. Safe to re-run — collections are replaced, not appended to.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
MGMT=http://127.0.0.1:3333/api/management/database
|
||||
COMPOSE=(docker compose)
|
||||
|
||||
echo "==> waiting for the management app"
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -fsS -o /dev/null "$MGMT/../../docs" 2>/dev/null || curl -fsS -o /dev/null -X POST "$MGMT/sync-indexes" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "==> seeding collections (this takes a minute or two)"
|
||||
curl -fsS --retry 30 --retry-all-errors --retry-delay 10 \
|
||||
-X POST "$MGMT/seed?with-indexes=true" >/dev/null
|
||||
echo " done"
|
||||
|
||||
# The legacy file service (course/topic attachments, as opposed to the newer
|
||||
# /api/v3/file API) reads its S3 credentials from a storageproviders document
|
||||
# rather than from the environment, and there is deliberately no seed data for
|
||||
# it. Without this, legacy uploads fail with a provider-not-found error.
|
||||
echo "==> registering MinIO as the legacy storage provider"
|
||||
S3_KEY=$(grep -E '^S3_KEY=' env/shared.env | cut -d= -f2-)
|
||||
SECRET=$(curl -fsS -X POST "$MGMT/encrypt-plain-text" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(printf '{"plainText":"miniouser","key":"%s"}' "$S3_KEY")")
|
||||
|
||||
"${COMPOSE[@]}" exec -T mongo mongosh schulcloud --quiet --eval "
|
||||
const id = ObjectId('62949a4003839b6162aa566b');
|
||||
db.storageproviders.replaceOne({ _id: id }, {
|
||||
_id: id, isShared: true, region: 'eu-central-1', type: 'S3',
|
||||
// Not minio:9000: this one endpoint also goes into every signed URL, and
|
||||
// those are opened by the browser and the MCP server on the host. The
|
||||
// minio-loopback service makes localhost:9900 reach MinIO from the api too.
|
||||
endpointUrl: 'http://localhost:9900',
|
||||
accessKeyId: 'miniouser',
|
||||
secretAccessKey: '$SECRET',
|
||||
maxBuckets: 150, freeBuckets: 138,
|
||||
createdAt: new Date(), updatedAt: new Date(), __v: 0,
|
||||
}, { upsert: true });
|
||||
const r = db.schools.updateMany({}, { \$set: { storageProvider: id } });
|
||||
print(' schools linked to the provider: ' + r.modifiedCount);
|
||||
"
|
||||
|
||||
# The demo data is dated 2017-2018, and the v3 endpoints filter on those dates:
|
||||
# a course that has ended shows a student no tasks, and a task whose due date
|
||||
# passed years ago is in neither the open nor the finished list and has dropped
|
||||
# off the course page. The result is a student account that looks empty for
|
||||
# reasons that have nothing to do with whatever is being tested against it.
|
||||
#
|
||||
# So move the stale data into the present. Courses get a term around today.
|
||||
# Homework is shifted by one offset per date cluster, which keeps the relative
|
||||
# order — and so which tasks are past due, the ones carrying the graded
|
||||
# submissions — while landing the newest of them two weeks ago.
|
||||
echo "==> dating the demo data to the present"
|
||||
"${COMPOSE[@]}" exec -T mongo mongosh schulcloud --quiet --eval "
|
||||
const now = new Date();
|
||||
const courses = db.courses.updateMany({ untilDate: { \$lt: now } }, { \$set: {
|
||||
startDate: new Date(now.getTime() - 180 * 86400000),
|
||||
untilDate: new Date(now.getTime() + 185 * 86400000),
|
||||
} });
|
||||
print(' courses given a current term: ' + courses.modifiedCount);
|
||||
|
||||
let moved = 0;
|
||||
for (let pass = 0; pass < 10; pass++) {
|
||||
const cutoff = new Date(Date.now() - 365 * 86400000);
|
||||
const newest = db.homeworks.find({ dueDate: { \$lt: cutoff } }).sort({ dueDate: -1 }).limit(1).toArray()[0];
|
||||
if (!newest) break;
|
||||
const offset = (Date.now() - 14 * 86400000) - newest.dueDate.getTime();
|
||||
db.homeworks.find({ dueDate: { \$lt: cutoff } }).forEach((h) => {
|
||||
const set = {};
|
||||
for (const field of ['dueDate', 'availableDate', 'createdAt', 'updatedAt']) {
|
||||
if (h[field] instanceof Date) set[field] = new Date(h[field].getTime() + offset);
|
||||
}
|
||||
db.homeworks.updateOne({ _id: h._id }, { \$set: set });
|
||||
moved++;
|
||||
});
|
||||
}
|
||||
print(' homework brought forward: ' + moved);
|
||||
"
|
||||
|
||||
cat <<'ACCOUNTS'
|
||||
|
||||
==> ready — http://localhost:4400
|
||||
|
||||
Seeded accounts (the demo password differs by account — upstream quirk):
|
||||
|
||||
demo-schueler@schul-cloud.org student Fritz Schmidt schulcloud
|
||||
^ has graded submissions, incl. a feedback-only and a 100% one
|
||||
demo-lehrer@schul-cloud.org teacher Erika Meier schulcloud
|
||||
klara.fall@schul-cloud.org teacher Klara Fall Schulcloud1!
|
||||
^ owns Fritz's graded Biologie submissions
|
||||
lehrer@schul-cloud.org teacher Cord Carl Schulcloud1!
|
||||
admin@schul-cloud.org admin Thorsten Test Schulcloud1!
|
||||
*.qa@schul-cloud.org various Schulcloud1qa!
|
||||
|
||||
Sign in as the teacher to grade, as the student to see what grading looks
|
||||
like from the side our MCP server reads.
|
||||
ACCOUNTS
|
||||
601
local-instance/scripts/simulate-teacher.mjs
Executable file
601
local-instance/scripts/simulate-teacher.mjs
Executable file
@@ -0,0 +1,601 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Simulate a teacher's work against the LOCAL instance: create, change and
|
||||
* delete the things teachers actually create, so the MCP server can be exercised
|
||||
* against content the real account has never contained.
|
||||
*
|
||||
* This is the only thing in this repository that writes to a Schulcloud, and the
|
||||
* guard below is what keeps it that way. The MCP server itself stays read-only;
|
||||
* nothing here runs through it.
|
||||
*
|
||||
* node scripts/simulate-teacher.mjs create # build the fixture, print ids
|
||||
* node scripts/simulate-teacher.mjs update # rename/edit everything it made
|
||||
* node scripts/simulate-teacher.mjs delete # remove it again
|
||||
* node scripts/simulate-teacher.mjs files # (re)build only the file-manager part
|
||||
*
|
||||
* State lives in .simulate-teacher.json so the phases can be run one at a time
|
||||
* with MCP checks in between.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const STATE = join(ROOT, '.simulate-teacher.json');
|
||||
|
||||
// The server's own port, not the nginx origin: `/api/v1` is deliberately not
|
||||
// routed through the ingress (the live deployment does the same), and the
|
||||
// legacy services are the only way to create tasks and topics.
|
||||
const API = process.env.LOCAL_SC_API ?? 'http://127.0.0.1:3030';
|
||||
const FILES = process.env.LOCAL_SC_FILES ?? 'http://127.0.0.1:4444';
|
||||
|
||||
for (const [name, url] of [['LOCAL_SC_API', API], ['LOCAL_SC_FILES', FILES]]) {
|
||||
const { hostname } = new URL(url);
|
||||
if (hostname !== '127.0.0.1' && hostname !== 'localhost' && hostname !== '::1') {
|
||||
console.error(`refusing to run: ${name}=${url} is not a localhost address.`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const TEACHER = process.env.SIM_TEACHER ?? 'klara.fall@schul-cloud.org';
|
||||
const PASSWORD = process.env.SIM_PASSWORD ?? 'Schulcloud1!';
|
||||
/** Everything is created here, because the demo student is a member. */
|
||||
const COURSE = process.env.SIM_COURSE ?? '59a3c657a2049554a93fec3a'; // Biologie 9b
|
||||
const STUDENT_EMAIL = 'demo-schueler@schul-cloud.org';
|
||||
|
||||
let jwt = '';
|
||||
let me;
|
||||
|
||||
async function req(base, method, path, body, { raw = false } = {}) {
|
||||
const headers = { Authorization: `Bearer ${jwt}` };
|
||||
let payload;
|
||||
if (body instanceof FormData) {
|
||||
payload = body;
|
||||
} else if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
payload = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(`${base}${path}`, { method, headers, body: payload });
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`${method} ${path} -> ${res.status} ${text.slice(0, 400)}`);
|
||||
}
|
||||
if (raw) return text;
|
||||
return text ? JSON.parse(text) : undefined;
|
||||
}
|
||||
|
||||
const v3 = (method, path, body, opts) => req(API, method, `/api/v3${path}`, body, opts);
|
||||
const v1 = (method, path, body, opts) => req(API, method, `/api/v1${path}`, body, opts);
|
||||
const files = (method, path, body, opts) => req(FILES, method, `/api/v3/file${path}`, body, opts);
|
||||
|
||||
const log = (...a) => console.log(' ', ...a);
|
||||
const step = (s) => console.log(`\n== ${s} ==`);
|
||||
|
||||
function loadState() {
|
||||
return existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : {};
|
||||
}
|
||||
function saveState(s) {
|
||||
writeFileSync(STATE, JSON.stringify(s, null, '\t') + '\n');
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const res = await fetch(`${API}/api/v3/authentication/local`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: TEACHER, password: PASSWORD }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`login failed: ${res.status} ${await res.text()}`);
|
||||
jwt = (await res.json()).accessToken;
|
||||
me = await v3('GET', '/me');
|
||||
log(`signed in as ${me.user.firstName} ${me.user.lastName} (${me.roles.map((r) => r.name).join(', ')})`);
|
||||
}
|
||||
|
||||
/** The demo student, so created content is visible to the account under test. */
|
||||
async function studentId() {
|
||||
const found = await v1('GET', `/users?email=${encodeURIComponent(STUDENT_EMAIL)}`);
|
||||
const user = found.data?.[0] ?? found[0];
|
||||
if (!user) throw new Error(`could not find ${STUDENT_EMAIL}`);
|
||||
return user._id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- create ---
|
||||
|
||||
async function create() {
|
||||
const s = loadState();
|
||||
// Saved after every step, not at the end: a failure halfway through used to
|
||||
// leave created objects with no record of their ids, and so no way to delete
|
||||
// them again.
|
||||
const keep = (k, v) => { s[k] = v; saveState(s); return v; };
|
||||
const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ');
|
||||
const student = await studentId();
|
||||
|
||||
step('course');
|
||||
const course = await v3('POST', '/courses', { name: `MCP-Test Kurs (${stamp})` });
|
||||
keep('courseId', course.courseId ?? course.id ?? course._id);
|
||||
// v3 creates the course but takes no members; the legacy service does.
|
||||
await v1('PATCH', `/courses/${s.courseId}`, { userIds: [student], teacherIds: [me.user.id] });
|
||||
log(`course ${s.courseId} with the demo student enrolled`);
|
||||
|
||||
step('room');
|
||||
const room = await v3('POST', '/rooms', {
|
||||
name: `MCP-Test Raum (${stamp})`,
|
||||
color: 'blue',
|
||||
features: [],
|
||||
});
|
||||
keep('roomId', room.id);
|
||||
log(`room ${s.roomId}`);
|
||||
|
||||
step('room contents');
|
||||
// Rooms are the newer collaboration space, separate from courses: a room has
|
||||
// its own members and its own boards, and appears under "Räume" in the UI
|
||||
// while courses appear under "Kurse" at a /rooms/... url.
|
||||
await v3('PATCH', `/rooms/${s.roomId}/members/add`, { userIds: [student] });
|
||||
log('demo student added as a room member');
|
||||
|
||||
const roomBoard = await v3('POST', '/boards', {
|
||||
title: `MCP-Test Raum-Board (${stamp})`,
|
||||
parentId: s.roomId,
|
||||
parentType: 'room',
|
||||
layout: 'columns',
|
||||
});
|
||||
keep('roomBoardId', roomBoard.id);
|
||||
const roomColumn = await v3('POST', `/boards/${s.roomBoardId}/columns`);
|
||||
await v3('PATCH', `/columns/${roomColumn.id}/title`, { title: 'Projektarbeit' });
|
||||
const roomCard = await v3('POST', `/columns/${roomColumn.id}/cards`);
|
||||
await v3('PATCH', `/cards/${roomCard.id}/title`, { title: 'Aufgabenverteilung' });
|
||||
const roomText = await v3('POST', `/cards/${roomCard.id}/elements`, { type: 'richText' });
|
||||
await v3('PATCH', `/elements/${roomText.id}/content`, {
|
||||
data: {
|
||||
content: {
|
||||
text: '<p>Raum-Inhalt, nicht Kurs-Inhalt. Suchbegriff: Projektsteuerung.</p>',
|
||||
inputFormat: 'richTextCk5',
|
||||
},
|
||||
type: 'richText',
|
||||
},
|
||||
});
|
||||
// A file in a room board, so the mirror path (room name, not course name) and
|
||||
// the CLI manifest are exercised for rooms too.
|
||||
const roomFileEl = await v3('POST', `/cards/${roomCard.id}/elements`, { type: 'file' });
|
||||
keep('roomFileId', await upload(roomFileEl.id, 'projektplan.txt', 'text/plain',
|
||||
'Projektplan\n\nMeilenstein 1: Anforderungen. Stichwort: Projektsteuerung.\n'));
|
||||
|
||||
await v3('PATCH', `/boards/${s.roomBoardId}/visibility`, { isVisible: true });
|
||||
log(`room board ${s.roomBoardId} published, with one card`);
|
||||
|
||||
// A second room the student is NOT in, so "only rooms I belong to" is testable.
|
||||
const other = await v3('POST', '/rooms', {
|
||||
name: `MCP-Test Raum ohne Zugriff (${stamp})`,
|
||||
color: 'red',
|
||||
features: [],
|
||||
});
|
||||
keep('roomWithoutStudentId', other.id);
|
||||
log(`second room ${other.id} left without the student on purpose`);
|
||||
|
||||
step('team');
|
||||
// Teams cannot be created through the API: the legacy service registers
|
||||
// ['find','get','update','patch','remove'] and no 'create'
|
||||
// (schulcloud-server src/services/teams/index.js), and v3 has no team route
|
||||
// beyond news and create-room. POST /teams answers 405. So the simulation
|
||||
// adopts a seeded team and edits that instead — which is all the MCP server
|
||||
// needs, since it reads teams only through api_get.
|
||||
const teams = await v1('GET', '/teams');
|
||||
const existing = (teams.data ?? [])[0];
|
||||
if (existing) {
|
||||
keep('teamId', existing._id);
|
||||
log(`adopted seeded team ${existing._id} (${existing.name}) — creation is not exposed`);
|
||||
} else {
|
||||
log('no seeded team to adopt; skipping');
|
||||
}
|
||||
|
||||
step('topic (legacy lesson) in Biologie 9b');
|
||||
const lesson = await v1('POST', '/lessons', {
|
||||
name: `MCP-Test Thema (${stamp})`,
|
||||
courseId: COURSE,
|
||||
hidden: false,
|
||||
contents: [
|
||||
{
|
||||
title: 'Einführung',
|
||||
hidden: false,
|
||||
component: 'text',
|
||||
content: { text: '<p>Dieses Thema prüft, was der MCP-Server aus einem Thema liest.</p>' },
|
||||
},
|
||||
],
|
||||
});
|
||||
keep('lessonId', lesson._id);
|
||||
log(`lesson ${s.lessonId}`);
|
||||
|
||||
step('task (homework) in Biologie 9b');
|
||||
const task = await v1('POST', '/homework', {
|
||||
name: `MCP-Test Aufgabe (${stamp})`,
|
||||
description: '<p>Beschreibe in drei Sätzen, was ein Neuron tut.</p>',
|
||||
courseId: COURSE,
|
||||
availableDate: new Date(Date.now() - 86400_000).toISOString(),
|
||||
dueDate: new Date(Date.now() + 7 * 86400_000).toISOString(),
|
||||
private: false,
|
||||
teacherId: me.user.id,
|
||||
schoolId: me.school.id,
|
||||
});
|
||||
keep('taskId', task._id);
|
||||
log(`task ${s.taskId}`);
|
||||
|
||||
step('column board in Biologie 9b');
|
||||
const board = await v3('POST', '/boards', {
|
||||
title: `MCP-Test Board (${stamp})`,
|
||||
parentId: COURSE,
|
||||
parentType: 'course',
|
||||
layout: 'columns',
|
||||
});
|
||||
keep('boardId', board.id);
|
||||
const column = await v3('POST', `/boards/${s.boardId}/columns`);
|
||||
keep('columnId', column.id);
|
||||
await v3('PATCH', `/columns/${s.columnId}/title`, { title: 'Material' });
|
||||
const card = await v3('POST', `/columns/${s.columnId}/cards`);
|
||||
keep('cardId', card.id);
|
||||
await v3('PATCH', `/cards/${s.cardId}/title`, { title: 'Das Nervensystem' });
|
||||
log(`board ${s.boardId} / column ${s.columnId} / card ${s.cardId}`);
|
||||
|
||||
step('card elements');
|
||||
const rich = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'richText' });
|
||||
keep('richTextId', rich.id);
|
||||
await v3('PATCH', `/elements/${s.richTextId}/content`, {
|
||||
data: {
|
||||
content: {
|
||||
text: '<p>Ein <strong>Neuron</strong> leitet Reize weiter. Suchbegriff: Synapsenspalt.</p>',
|
||||
inputFormat: 'richTextCk5',
|
||||
},
|
||||
type: 'richText',
|
||||
},
|
||||
});
|
||||
log(`richText ${s.richTextId}`);
|
||||
|
||||
const link = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'link' });
|
||||
keep('linkId', link.id);
|
||||
await v3('PATCH', `/elements/${s.linkId}/content`, {
|
||||
data: {
|
||||
content: { url: 'https://www.dbildungscloud.de/', title: 'dBildungscloud', description: '', imageUrl: '', originalImageUrl: '' },
|
||||
type: 'link',
|
||||
},
|
||||
});
|
||||
log(`link ${s.linkId}`);
|
||||
|
||||
const pad = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'collaborativeTextEditor' });
|
||||
keep('padElementId', pad.id);
|
||||
log(`collaborativeTextEditor (Etherpad) ${s.padElementId}`);
|
||||
|
||||
const folder = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'fileFolder' });
|
||||
keep('folderId', folder.id);
|
||||
await v3('PATCH', `/elements/${s.folderId}/content`, {
|
||||
data: { content: { title: 'Arbeitsblätter' }, type: 'fileFolder' },
|
||||
});
|
||||
log(`fileFolder (directory) ${s.folderId}`);
|
||||
|
||||
step('files');
|
||||
const fileEl = await v3('POST', `/cards/${s.cardId}/elements`, { type: 'file' });
|
||||
keep('fileElementId', fileEl.id);
|
||||
keep('fileId', await upload(s.fileElementId, 'nervensystem-notiz.txt', 'text/plain',
|
||||
'Das Nervensystem\n\nReizleitung erfolgt ueber Synapsen. Stichwort: Synapsenspalt.\n'));
|
||||
log(`file element ${s.fileElementId} holding file ${s.fileId}`);
|
||||
|
||||
keep('folderFileId', await upload(s.folderId, 'arbeitsblatt-1.txt', 'text/plain',
|
||||
'Arbeitsblatt 1\n\nAufgabe: Beschrifte die Teile eines Neurons.\n'));
|
||||
log(`file ${s.folderFileId} inside the directory`);
|
||||
|
||||
step('publishing');
|
||||
// A board is created as a draft. Students get 403 on it while the course page
|
||||
// still lists its title, so the fixture needs both states to be useful.
|
||||
await v3('PATCH', `/boards/${s.boardId}/visibility`, { isVisible: true });
|
||||
log('main board published');
|
||||
|
||||
const draft = await v3('POST', '/boards', {
|
||||
title: `MCP-Test Entwurf, unveröffentlicht (${stamp})`,
|
||||
parentId: COURSE,
|
||||
parentType: 'course',
|
||||
layout: 'columns',
|
||||
});
|
||||
keep('draftBoardId', draft.id);
|
||||
log(`second board left unpublished on purpose: ${draft.id}`);
|
||||
|
||||
saveState(s);
|
||||
await fileManager();
|
||||
console.log(`\nstate written to ${STATE}`);
|
||||
summary(loadState());
|
||||
}
|
||||
|
||||
/** files-storage attaches bytes to a board node (element) id, not to the card. */
|
||||
async function upload(parentId, name, type, body) {
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([body], { type }), name);
|
||||
const record = await files('POST', `/upload/school/${me.school.id}/boardnodes/${parentId}`, form);
|
||||
return record.id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- file manager ---
|
||||
//
|
||||
// The file manager ("Dateien": Persönliche, Kurs-, Team- and Geteilte Dateien)
|
||||
// is the legacy file system, a different store from files-storage above, with a
|
||||
// real folder tree. Many teachers use nothing else, so the MCP server's fs_*
|
||||
// tools need content there. Its services are only reachable on the server's own
|
||||
// port, like the other /api/v1 writes in this script.
|
||||
|
||||
const STUDENT_PASSWORD = process.env.SIM_STUDENT_PASSWORD ?? 'schulcloud';
|
||||
const TEAM_MEMBER_ROLE = '5bb5c190fb457b1c3c0c7e0f'; // "teammember" in the seed
|
||||
|
||||
/** Runs `fn` signed in as another account, then restores the teacher. */
|
||||
async function asUser(email, password, fn) {
|
||||
const saved = { jwt, me };
|
||||
const res = await fetch(`${API}/api/v3/authentication/local`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: email, password }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`login as ${email} failed: ${res.status}`);
|
||||
jwt = (await res.json()).accessToken;
|
||||
me = await v3('GET', '/me');
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
({ jwt, me } = saved);
|
||||
}
|
||||
}
|
||||
|
||||
async function legacyDir(name, owner, parent) {
|
||||
return (await v1('POST', '/fileStorage/directories', { name, owner, parent }))._id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser's own upload sequence: a signed PUT url, the bytes, then the
|
||||
* record. The url comes from the server, so it is held to the same localhost
|
||||
* rule as everything else this script writes to.
|
||||
*/
|
||||
async function legacyUpload({ owner, parent, name, type, body }) {
|
||||
const bytes = Buffer.from(body);
|
||||
const signed = await v1('POST', '/fileStorage/signedUrl', { parent, filename: name, fileType: type });
|
||||
const { hostname } = new URL(signed.url);
|
||||
if (hostname !== '127.0.0.1' && hostname !== 'localhost') {
|
||||
throw new Error(`refusing to upload to ${hostname}: not a localhost address (see minio-loopback in docker-compose.yml)`);
|
||||
}
|
||||
const put = await fetch(signed.url, { method: 'PUT', headers: signed.header, body: bytes });
|
||||
if (!put.ok) throw new Error(`PUT ${name} to storage -> ${put.status} ${(await put.text()).slice(0, 200)}`);
|
||||
const record = await v1('POST', '/fileStorage', {
|
||||
name,
|
||||
owner,
|
||||
parent,
|
||||
type,
|
||||
size: bytes.length,
|
||||
storageFileName: signed.header['x-amz-meta-flat-name'],
|
||||
});
|
||||
return record._id;
|
||||
}
|
||||
|
||||
async function fileManager() {
|
||||
const s = loadState();
|
||||
if (!s.courseId) throw new Error('run `create` first: the file-manager fixture lives in its course');
|
||||
const keep = (key, value) => {
|
||||
s[key] = value;
|
||||
saveState(s);
|
||||
};
|
||||
const student = await studentId();
|
||||
|
||||
step('file manager: Kurs-Dateien');
|
||||
keep('fmCourseRootFileId', await legacyUpload({
|
||||
owner: s.courseId, name: 'Kursplan.txt', type: 'text/plain',
|
||||
body: 'Kursplan Biologie\n\nThemen: Zelle, Gewebe, Organe. Stichwort: Photosynthese-Lichtreaktion.\n',
|
||||
}));
|
||||
keep('fmCourseDirId', await legacyDir('Arbeitsblätter', s.courseId));
|
||||
keep('fmCourseFileId', await legacyUpload({
|
||||
owner: s.courseId, parent: s.fmCourseDirId, name: 'Blatt 1 - Zellorganellen.txt', type: 'text/plain',
|
||||
body: 'Blatt 1: Zellorganellen\n\nBeschrifte die Mitochondrienmembran und das endoplasmatische Retikulum.\n',
|
||||
}));
|
||||
keep('fmCourseSubDirId', await legacyDir('Woche 1', s.courseId, s.fmCourseDirId));
|
||||
keep('fmCourseDeepFileId', await legacyUpload({
|
||||
owner: s.courseId, parent: s.fmCourseSubDirId, name: 'Blatt 2 - Gewebe.txt', type: 'text/plain',
|
||||
body: 'Blatt 2: Gewebe\n\nVergleiche Epithelgewebe und Bindegewebe.\n',
|
||||
}));
|
||||
log(`course root file, folder "Arbeitsblätter" with a file, and "Woche 1" nested inside it`);
|
||||
|
||||
if (s.teamId) {
|
||||
step('file manager: Team-Dateien');
|
||||
// Teams cannot be created (see the README), and the adopted one does not
|
||||
// include the demo student, whose view is the one under test.
|
||||
const team = await v1('GET', `/teams/${s.teamId}`);
|
||||
if (!team.userIds.some((entry) => String(entry.userId?._id ?? entry.userId) === student)) {
|
||||
const userIds = team.userIds.map((entry) => ({
|
||||
userId: String(entry.userId?._id ?? entry.userId),
|
||||
role: String(entry.role?._id ?? entry.role),
|
||||
schoolId: String(entry.schoolId?._id ?? entry.schoolId),
|
||||
}));
|
||||
userIds.push({ userId: student, role: TEAM_MEMBER_ROLE, schoolId: me.school.id });
|
||||
await v1('PATCH', `/teams/${s.teamId}`, { userIds });
|
||||
keep('fmStudentAddedToTeam', true);
|
||||
log('demo student added to the team');
|
||||
}
|
||||
keep('fmTeamDirId', await legacyDir('Projekt', s.teamId));
|
||||
keep('fmTeamFileId', await legacyUpload({
|
||||
owner: s.teamId, parent: s.fmTeamDirId, name: 'Projektplan.txt', type: 'text/plain',
|
||||
body: 'Projektplan\n\nMeilenstein Chlorophyll bis Freitag.\n',
|
||||
}));
|
||||
log('team folder "Projekt" with a file');
|
||||
}
|
||||
|
||||
step('file manager: Persönliche Dateien (the student\'s own)');
|
||||
// No `owner` for personal files, exactly as the upload page sends none: the
|
||||
// server decides the owner model as "a course, or else a team", so passing a
|
||||
// user id records the folder as a team's, and every later permission check on
|
||||
// it then dereferences a team that does not exist.
|
||||
await asUser(STUDENT_EMAIL, STUDENT_PASSWORD, async () => {
|
||||
keep('fmStudentDirId', await legacyDir('Notizen'));
|
||||
keep('fmStudentFileId', await legacyUpload({
|
||||
parent: s.fmStudentDirId, name: 'Lernzettel.txt', type: 'text/plain',
|
||||
body: 'Lernzettel\n\nRibosomenfabrik: Proteinbiosynthese am rauen ER.\n',
|
||||
}));
|
||||
});
|
||||
log('student folder "Notizen" with a file');
|
||||
|
||||
step('file manager: Geteilte Dateien');
|
||||
keep('fmSharedFileId', await legacyUpload({
|
||||
name: 'Geteilt vom Lehrer.txt', type: 'text/plain',
|
||||
body: 'Zusatzmaterial\n\nDie Zellkernhuelle trennt Kernplasma und Zytoplasma.\n',
|
||||
}));
|
||||
// What accepting a share link does in the legacy client: a read-only user
|
||||
// permission. Not the permission service, which writes `refOwnerModel` where
|
||||
// the "shared with me" query reads `refPermModel`, so its shares never show.
|
||||
const shared = await v1('GET', `/files/${s.fmSharedFileId}`);
|
||||
await v1('PATCH', `/files/${s.fmSharedFileId}`, {
|
||||
permissions: [
|
||||
...shared.permissions,
|
||||
{ refId: student, refPermModel: 'user', read: true, write: false, delete: false, create: false },
|
||||
],
|
||||
});
|
||||
log('teacher file shared read-only with the student');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- update ---
|
||||
|
||||
async function update() {
|
||||
const s = loadState();
|
||||
if (!s.boardId) throw new Error('nothing to update — run `create` first');
|
||||
const stamp = new Date().toISOString().slice(11, 16);
|
||||
|
||||
step('renames');
|
||||
await v1('PATCH', `/courses/${s.courseId}`, { name: `MCP-Test Kurs [umbenannt ${stamp}]` });
|
||||
log('course renamed');
|
||||
await v3('PATCH', `/boards/${s.boardId}/title`, { title: `MCP-Test Board [umbenannt ${stamp}]` });
|
||||
log('board renamed');
|
||||
await v3('PATCH', `/columns/${s.columnId}/title`, { title: 'Material (überarbeitet)' });
|
||||
await v3('PATCH', `/cards/${s.cardId}/title`, { title: 'Das Nervensystem — überarbeitet' });
|
||||
log('column and card renamed');
|
||||
await v1('PATCH', `/homework/${s.taskId}`, { name: `MCP-Test Aufgabe [umbenannt ${stamp}]` });
|
||||
log('task renamed');
|
||||
await v1('PATCH', `/lessons/${s.lessonId}`, { name: `MCP-Test Thema [umbenannt ${stamp}]` });
|
||||
log('topic renamed');
|
||||
if (s.teamId) {
|
||||
const team = await v1('GET', `/teams/${s.teamId}`);
|
||||
await v1('PATCH', `/teams/${s.teamId}`, { name: `${team.name.replace(/ \[MCP .*$/, '')} [MCP ${stamp}]` });
|
||||
log('team renamed (the one write teams do allow)');
|
||||
}
|
||||
await v3('PUT', `/rooms/${s.roomId}`, { name: `MCP-Test Raum [umbenannt ${stamp}]`, color: 'green', features: [] });
|
||||
log('room renamed');
|
||||
|
||||
step('content edits');
|
||||
await v3('PATCH', `/elements/${s.richTextId}/content`, {
|
||||
data: {
|
||||
content: {
|
||||
text: '<p>Ein <strong>Neuron</strong> leitet Reize weiter. Neuer Suchbegriff: Ranvierscher Schnürring.</p>',
|
||||
inputFormat: 'richTextCk5',
|
||||
},
|
||||
type: 'richText',
|
||||
},
|
||||
});
|
||||
log('rich text rewritten (new search term: Ranvierscher Schnürring)');
|
||||
await files('PATCH', `/rename/${s.fileId}`, { fileName: 'nervensystem-notiz-v2.txt' });
|
||||
log('file renamed');
|
||||
|
||||
if (s.fmCourseFileId) {
|
||||
step('file manager edits');
|
||||
await v1('POST', '/fileStorage/rename', { id: s.fmCourseFileId, newName: 'Blatt 1 - Zellorganellen (korrigiert).txt' });
|
||||
log('course file renamed');
|
||||
s.fmCourseAddedFileId = await legacyUpload({
|
||||
owner: s.courseId, parent: s.fmCourseSubDirId, name: 'Blatt 3 - Organe.txt', type: 'text/plain',
|
||||
body: 'Blatt 3: Organe\n\nNeuer Suchbegriff: Nephronschleife.\n',
|
||||
});
|
||||
log('course file added in "Woche 1" (new search term: Nephronschleife)');
|
||||
}
|
||||
|
||||
saveState({ ...s, updated: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- delete ---
|
||||
|
||||
async function remove() {
|
||||
const s = loadState();
|
||||
if (!s.boardId) throw new Error('nothing to delete — run `create` first');
|
||||
|
||||
step('deleting what was created');
|
||||
const tries = [
|
||||
['file', () => files('DELETE', `/delete/${s.folderFileId}`)],
|
||||
['room file', () => files('DELETE', `/delete/${s.roomFileId}`)],
|
||||
['file element', () => v3('DELETE', `/elements/${s.fileElementId}`)],
|
||||
['fileFolder element', () => v3('DELETE', `/elements/${s.folderId}`)],
|
||||
['etherpad element', () => v3('DELETE', `/elements/${s.padElementId}`)],
|
||||
['link element', () => v3('DELETE', `/elements/${s.linkId}`)],
|
||||
['card', () => v3('DELETE', `/cards/${s.cardId}`)],
|
||||
['column', () => v3('DELETE', `/columns/${s.columnId}`)],
|
||||
['board', () => v3('DELETE', `/boards/${s.boardId}`)],
|
||||
['draft board', () => v3('DELETE', `/boards/${s.draftBoardId}`)],
|
||||
['task', () => v3('DELETE', `/tasks/${s.taskId}`)],
|
||||
['topic', () => v3('DELETE', `/lessons/${s.lessonId}`)],
|
||||
['room board', () => v3('DELETE', `/boards/${s.roomBoardId}`)],
|
||||
['room', () => v3('DELETE', `/rooms/${s.roomId}`)],
|
||||
['second room', () => v3('DELETE', `/rooms/${s.roomWithoutStudentId}`)],
|
||||
['course', () => v1('DELETE', `/courses/${s.courseId}`)],
|
||||
];
|
||||
// File-manager content goes first, while its course and team still exist.
|
||||
const fileManagerTries = [
|
||||
['file-manager course files', async () => {
|
||||
for (const id of [s.fmCourseAddedFileId, s.fmCourseDeepFileId, s.fmCourseFileId, s.fmCourseRootFileId]) {
|
||||
if (id) await v1('DELETE', `/fileStorage?_id=${id}`);
|
||||
}
|
||||
}],
|
||||
['file-manager course folders', async () => {
|
||||
for (const id of [s.fmCourseSubDirId, s.fmCourseDirId]) if (id) await v1('DELETE', `/fileStorage/directories?_id=${id}`);
|
||||
}],
|
||||
['file-manager team content', async () => {
|
||||
if (s.fmTeamFileId) await v1('DELETE', `/fileStorage?_id=${s.fmTeamFileId}`);
|
||||
if (s.fmTeamDirId) await v1('DELETE', `/fileStorage/directories?_id=${s.fmTeamDirId}`);
|
||||
}],
|
||||
['shared file', async () => {
|
||||
if (s.fmSharedFileId) await v1('DELETE', `/fileStorage?_id=${s.fmSharedFileId}`);
|
||||
}],
|
||||
['student personal files', async () => {
|
||||
if (!s.fmStudentFileId && !s.fmStudentDirId) return;
|
||||
await asUser(STUDENT_EMAIL, STUDENT_PASSWORD, async () => {
|
||||
if (s.fmStudentFileId) await v1('DELETE', `/fileStorage?_id=${s.fmStudentFileId}`);
|
||||
if (s.fmStudentDirId) await v1('DELETE', `/fileStorage/directories?_id=${s.fmStudentDirId}`);
|
||||
});
|
||||
}],
|
||||
['student team membership', async () => {
|
||||
if (!s.fmStudentAddedToTeam || !s.teamId) return;
|
||||
const student = await studentId();
|
||||
const team = await v1('GET', `/teams/${s.teamId}`);
|
||||
const userIds = team.userIds
|
||||
.map((entry) => ({
|
||||
userId: String(entry.userId?._id ?? entry.userId),
|
||||
role: String(entry.role?._id ?? entry.role),
|
||||
schoolId: String(entry.schoolId?._id ?? entry.schoolId),
|
||||
}))
|
||||
.filter((entry) => entry.userId !== student);
|
||||
await v1('PATCH', `/teams/${s.teamId}`, { userIds });
|
||||
}],
|
||||
];
|
||||
tries.unshift(...fileManagerTries);
|
||||
|
||||
for (const [what, fn] of tries) {
|
||||
try {
|
||||
await fn();
|
||||
log(`deleted ${what}`);
|
||||
} catch (err) {
|
||||
log(`could NOT delete ${what}: ${String(err.message).slice(0, 160)}`);
|
||||
}
|
||||
}
|
||||
unlinkSync(STATE);
|
||||
console.log(`\nstate file removed`);
|
||||
}
|
||||
|
||||
function summary(s) {
|
||||
console.log('\nids for the MCP side:');
|
||||
for (const [k, v] of Object.entries(s)) console.log(` ${k.padEnd(16)} ${v}`);
|
||||
}
|
||||
|
||||
const phase = process.argv[2] ?? 'create';
|
||||
await login();
|
||||
if (phase === 'create') await create();
|
||||
else if (phase === 'update') await update();
|
||||
else if (phase === 'delete') await remove();
|
||||
else if (phase === 'files') {
|
||||
await fileManager();
|
||||
summary(loadState());
|
||||
} else if (phase === 'show') summary(loadState());
|
||||
else {
|
||||
console.error(`unknown phase ${phase}; expected create | update | delete | files | show`);
|
||||
process.exit(2);
|
||||
}
|
||||
5
package-lock.json
generated
5
package-lock.json
generated
@@ -18,6 +18,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"bin": {
|
||||
"schulcloud": "dist/bin/cli.js",
|
||||
"schulcloud-mcp": "dist/bin/stdio.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -981,7 +982,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -1269,7 +1269,6 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz",
|
||||
"integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -1873,7 +1872,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
|
||||
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
@@ -2637,7 +2635,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -17,11 +17,12 @@
|
||||
"start": "node dist/bin/http.js",
|
||||
"stdio": "node dist/bin/stdio.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "node --test test/*.test.ts",
|
||||
"test": "node --test --experimental-strip-types test/*.test.ts",
|
||||
"probe": "node --env-file=.env scripts/probe.mjs",
|
||||
"smoke": "node --env-file=.env scripts/smoke.mjs",
|
||||
"session-diagnose": "node --env-file=.env scripts/session-diagnose.mjs",
|
||||
"keepalive-status": "bash scripts/keepalive-status.sh",
|
||||
"publish-image": "bash scripts/publish-image.sh",
|
||||
"cli": "node dist/bin/cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -8,7 +8,13 @@
|
||||
*/
|
||||
import { cp, mkdir } from 'node:fs/promises';
|
||||
|
||||
const assets = [['src/store/migrations', 'dist/store/migrations']];
|
||||
const assets = [
|
||||
['src/store/migrations', 'dist/store/migrations'],
|
||||
// The web app's HTML, CSS and script. Real files rather than strings in a
|
||||
// module, so an editor and a linter can read them — which only works if the
|
||||
// build puts them next to the module that serves them.
|
||||
['src/http/app', 'dist/http/app'],
|
||||
];
|
||||
|
||||
for (const [from, to] of assets) {
|
||||
await mkdir(to, { recursive: true });
|
||||
|
||||
140
scripts/export-apple-notes.js
Executable file
140
scripts/export-apple-notes.js
Executable file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env osascript -l JavaScript
|
||||
/**
|
||||
* Exports Apple Notes to one JSON object per line, on stdout.
|
||||
*
|
||||
* Run this **on the Mac that has the notes**:
|
||||
*
|
||||
* osascript -l JavaScript scripts/export-apple-notes.js > notes.ndjson
|
||||
* osascript -l JavaScript scripts/export-apple-notes.js --folder Deutsch > deutsch.ndjson
|
||||
*
|
||||
* then hand the file to `schulcloud note import notes.ndjson`.
|
||||
*
|
||||
* Why this exists at all: Notes.app has no export. Its database is a Core Data
|
||||
* store whose bodies are compressed protobuf, and the iCloud copy is encrypted,
|
||||
* so scripting the app is not the clumsy route to the notes — it is the only
|
||||
* one. The first run raises a macOS permission dialog ("Terminal wants access
|
||||
* to Notes"); without it every note comes back empty.
|
||||
*
|
||||
* JXA and not AppleScript because it can serialise JSON, and because reading
|
||||
* properties one note at a time is what keeps a locked note from aborting the
|
||||
* run rather than a language preference.
|
||||
*/
|
||||
|
||||
ObjC.import('stdlib');
|
||||
|
||||
function run(argv) {
|
||||
const options = parseArguments(argv);
|
||||
const notes = Application('Notes');
|
||||
notes.includeStandardAdditions = true;
|
||||
|
||||
let items;
|
||||
try {
|
||||
items = notes.notes();
|
||||
} catch (error) {
|
||||
return fail(
|
||||
'Could not read Notes. Grant the terminal access under System Settings → Privacy & Security → ' +
|
||||
'Automation, then run this again.\n' + error,
|
||||
);
|
||||
}
|
||||
|
||||
let written = 0;
|
||||
let skipped = 0;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const note = items[i];
|
||||
const record = readNote(note);
|
||||
if (!record) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (options.folder && (record.folder || '').toLowerCase().indexOf(options.folder.toLowerCase()) === -1) continue;
|
||||
// One object per line, so a huge export streams and a bad note costs one line.
|
||||
console.log(JSON.stringify(record));
|
||||
written++;
|
||||
}
|
||||
|
||||
// stderr, so it never lands in the file being redirected.
|
||||
log('Exported ' + written + ' note(s)' + (skipped > 0 ? ', skipped ' + skipped + ' unreadable' : '') + '.');
|
||||
return '';
|
||||
}
|
||||
|
||||
function readNote(note) {
|
||||
try {
|
||||
// Read the body first: it is the property a locked note refuses, and
|
||||
// there is no point building a record we cannot fill.
|
||||
const body = note.body();
|
||||
return {
|
||||
id: safe(function () { return note.id(); }, ''),
|
||||
name: safe(function () { return note.name(); }, ''),
|
||||
body: body || '',
|
||||
folder: safe(function () { return folderPath(note.container()); }, ''),
|
||||
created: safe(function () { return iso(note.creationDate()); }, ''),
|
||||
modified: safe(function () { return iso(note.modificationDate()); }, ''),
|
||||
};
|
||||
} catch (error) {
|
||||
// A locked note, or one iCloud has not downloaded. Reported, not dropped
|
||||
// silently: a missing lesson is worse than a line in the file.
|
||||
return {
|
||||
id: safe(function () { return note.id(); }, ''),
|
||||
name: safe(function () { return note.name(); }, '(unreadable)'),
|
||||
body: '',
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** "Schule/Deutsch" — the import takes the last segment as the subject. */
|
||||
function folderPath(container) {
|
||||
const parts = [];
|
||||
let current = container;
|
||||
for (let depth = 0; current && depth < 8; depth++) {
|
||||
const name = safe(function () { return current.name(); }, '');
|
||||
if (!name) break;
|
||||
parts.unshift(name);
|
||||
current = safe(function () { return current.container(); }, null);
|
||||
}
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* A Date as local ISO, not UTC.
|
||||
*
|
||||
* `toISOString` would shift a note taken at 08:30 in Erfurt back to the
|
||||
* previous day for anything written before 01:00 or 02:00, and the date is
|
||||
* the whole point of the record.
|
||||
*/
|
||||
function iso(date) {
|
||||
if (!date) return '';
|
||||
const pad = function (value) { return String(value).padStart(2, '0'); };
|
||||
return (
|
||||
date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) +
|
||||
'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds())
|
||||
);
|
||||
}
|
||||
|
||||
function safe(read, fallback) {
|
||||
try {
|
||||
const value = read();
|
||||
return value === undefined || value === null ? fallback : value;
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const options = { folder: '' };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '--folder' && argv[i + 1]) options.folder = argv[++i];
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
$.NSFileHandle.fileHandleWithStandardError.writeData(
|
||||
$.NSString.alloc.initWithUTF8String(message + '\n').dataUsingEncoding($.NSUTF8StringEncoding),
|
||||
);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
log(message);
|
||||
$.exit(1);
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* Read-only. Usage: `node --env-file=.env scripts/probe.mjs`
|
||||
*/
|
||||
import { loadConfig } from '../dist/config.js';
|
||||
import { SchulcloudClient, SchulcloudApiError } from '../dist/schulcloud/client.js';
|
||||
import { SchulcloudClient, SchulcloudApiError } from '../dist/core/client.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const client = new SchulcloudClient(config);
|
||||
@@ -124,6 +124,84 @@ if (me) {
|
||||
const lesson = await client.getLesson(lessonId);
|
||||
console.log(` ok lesson ${lessonId} ("${lesson.name}", ${lesson.contents?.length ?? 0} section(s))`);
|
||||
}
|
||||
|
||||
// --- assumptions first established against the local 33.40 instance ----
|
||||
// Each of these was verified locally and against the deployment's ingress
|
||||
// table, not against this instance. A FAIL here means the matching feature
|
||||
// degrades (to bare ids, to no timetable, to no preview) rather than breaks.
|
||||
console.log('\nlegacy routes, classes, rooms:');
|
||||
const firstCourse = courses[0];
|
||||
if (firstCourse) {
|
||||
await expect('GET /api/v1/courses/{id} (description, teachers, timetable)', async () => {
|
||||
const legacy = await client.getLegacyCourse(firstCourse.id);
|
||||
const parts = [
|
||||
legacy.description ? 'description' : null,
|
||||
legacy.teacherIds?.length ? `${legacy.teacherIds.length} teacher id(s)` : null,
|
||||
legacy.times?.length ? `${legacy.times.length} timetable slot(s)` : null,
|
||||
].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(', ') : 'responds, but carries none of the fields';
|
||||
});
|
||||
}
|
||||
await expect('GET /api/v1/users/{me} (the only id -> name route)', async () => {
|
||||
const user = await client.getLegacyUser(me.user.id);
|
||||
return user.firstName || user.fullName ? 'resolves your own name' : 'responds without a name';
|
||||
});
|
||||
const teacherId = firstCourse ? (await client.getLegacyCourse(firstCourse.id).catch(() => undefined))?.teacherIds?.[0] : undefined;
|
||||
if (teacherId) {
|
||||
// Expected to be refused for a student: names then degrade to "not
|
||||
// visible to this account". Reported, not failed, either way.
|
||||
const seen = await client.getLegacyUser(teacherId).then(() => 'readable', (error) => `refused (${error.status ?? error.message})`);
|
||||
console.log(` — GET /api/v1/users/{teacher}: ${seen} — a student is expected to be refused`);
|
||||
}
|
||||
await expect('GET /api/v3/groups/class', async () => {
|
||||
const classes = await client.listClasses();
|
||||
const named = classes.filter((entry) => entry.teacherNames?.length).length;
|
||||
return `${classes.length} class(es), ${named} with teacher names`;
|
||||
});
|
||||
const rooms = await client.listRooms().catch(() => []);
|
||||
if (rooms[0]) {
|
||||
await expect('room allowedOperations is an object, not a list', async () => {
|
||||
const room = await client.getRoom(rooms[0].id);
|
||||
const ops = room.allowedOperations;
|
||||
if (Array.isArray(ops)) throw new Error('it is an array here — fix RoomItem.allowedOperations and rooms.ts');
|
||||
return `${Object.values(ops ?? {}).filter(Boolean).length} operation(s) granted`;
|
||||
});
|
||||
} else {
|
||||
console.log(' — no room to check allowedOperations against (in none is normal)');
|
||||
}
|
||||
|
||||
// The preview route is the answer for image-only PDFs, and it has two
|
||||
// undocumented enums. Only testable with a file whose preview is possible.
|
||||
if (boardId) {
|
||||
const skeleton = await client.getBoardSkeleton(boardId);
|
||||
const cards = await client.getCards(skeleton.columns.flatMap((c) => c.cards.map((x) => x.cardId)).slice(0, 20));
|
||||
const pdfElement = cards.flatMap((c) => c.elements).filter((e) => e.type === 'file' || e.type === 'fileFolder');
|
||||
let previewed = false;
|
||||
for (const element of pdfElement) {
|
||||
const page = await client
|
||||
.listFiles({ storageLocationId: me.school.id, parentType: 'boardnodes', parentId: element.id })
|
||||
.catch(() => undefined);
|
||||
const record = page?.data.find((file) => file.previewStatus === 'preview_possible');
|
||||
if (!record) continue;
|
||||
await expect(`GET /api/v3/file/preview (width=500, outputFormat=image/webp) on ${record.name}`, async () => {
|
||||
const preview = await client.getFilePreview(record, 500);
|
||||
return `${preview.mimeType}, ${preview.bytes.length} bytes`;
|
||||
});
|
||||
previewed = true;
|
||||
break;
|
||||
}
|
||||
if (!previewed) console.log(' — no previewable file among the sampled cards');
|
||||
}
|
||||
}
|
||||
|
||||
async function expect(label, run) {
|
||||
try {
|
||||
const detail = await run();
|
||||
console.log(` ok ${label}${detail ? ` (${detail})` : ''}`);
|
||||
} catch (error) {
|
||||
const status = error instanceof SchulcloudApiError ? error.status : '—';
|
||||
console.log(` FAIL ${label} → ${status} ${String(error.message).slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function summarize(result) {
|
||||
|
||||
54
scripts/publish-image.sh
Executable file
54
scripts/publish-image.sh
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the server image for the Pi (arm64) and x86 hosts (amd64) and pushes it
|
||||
# to the registry, tagged `latest` and with the commit it was built from.
|
||||
#
|
||||
# The Pi never builds: deploy/docker-compose.pi.yml pulls what this pushes.
|
||||
# Only a clean working tree is built, so a commit tag names exactly the code in
|
||||
# that commit — a tag built from uncommitted edits would name nothing.
|
||||
#
|
||||
# npm run publish-image # registry.mc02.dev/schulcloud-mcp
|
||||
# IMAGE=registry.example/other npm run publish-image
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE=${IMAGE:-registry.mc02.dev/schulcloud-mcp}
|
||||
PLATFORMS=${PLATFORMS:-linux/amd64,linux/arm64}
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "The working tree has uncommitted changes. Commit them first: the image's tag must name exactly one commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# An x86 machine builds arm64 under QEMU emulation, which has to be registered
|
||||
# once per boot. Registering needs a privileged container, so say how rather
|
||||
# than doing it unasked.
|
||||
#
|
||||
# The platform list is captured, not piped into grep: `grep -q` exits on the
|
||||
# first match, buildx then dies of SIGPIPE, and under `pipefail` that reads as
|
||||
# "no arm64" — which had this script refuse to publish while arm64 was
|
||||
# available the whole time.
|
||||
platforms=$(docker buildx inspect)
|
||||
if [[ "$PLATFORMS" == *linux/arm64* ]] && [[ "$platforms" != *linux/arm64* ]]; then
|
||||
echo "This builder cannot build linux/arm64. Register emulation (lasts until the next reboot) with:" >&2
|
||||
echo " docker run --privileged --rm tonistiigi/binfmt --install arm64" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
revision=$(git rev-parse HEAD)
|
||||
tag=$(git rev-parse --short HEAD)
|
||||
|
||||
# One multi-platform push needs either Docker's containerd image store or a
|
||||
# docker-container builder (`docker buildx create --use`); buildx says which
|
||||
# is missing if neither is there.
|
||||
docker buildx build \
|
||||
--platform "$PLATFORMS" \
|
||||
--tag "$IMAGE:latest" \
|
||||
--tag "$IMAGE:$tag" \
|
||||
--label org.opencontainers.image.title=schulcloud-mcp \
|
||||
--label org.opencontainers.image.source=https://git.mc02.dev/fabi/Schulcloud-MCP \
|
||||
--label "org.opencontainers.image.revision=$revision" \
|
||||
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--push .
|
||||
|
||||
echo "Pushed $IMAGE:$tag and $IMAGE:latest for $PLATFORMS."
|
||||
@@ -19,7 +19,7 @@
|
||||
* (default 150 — enough to pass the ~2 h mark where an open tab would strike).
|
||||
*/
|
||||
import { loadConfig } from '../dist/config.js';
|
||||
import { SchulcloudClient, SchulcloudApiError } from '../dist/schulcloud/client.js';
|
||||
import { SchulcloudClient, SchulcloudApiError } from '../dist/core/client.js';
|
||||
|
||||
const totalMinutes = Number(process.argv[2] ?? 150);
|
||||
const INTERVAL_MS = 10 * 60_000;
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
* Requires TSC_URL and TSC_JWT_COOKIE in the environment (load .env first).
|
||||
* Read-only — it never writes to Schulcloud.
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { loadConfig } from '../dist/config.js';
|
||||
@@ -14,6 +18,22 @@ import { closeServices, createServices } from '../dist/services.js';
|
||||
|
||||
const TOKEN = 'smoke-test-token-' + Math.random().toString(36).slice(2);
|
||||
process.env.MCP_AUTH_TOKEN = TOKEN;
|
||||
const PATH_SECRET = randomBytes(32).toString('hex');
|
||||
process.env.MCP_PATH_SECRET = PATH_SECRET;
|
||||
const CONNECTOR_TOKEN = randomBytes(32).toString('hex');
|
||||
process.env.MCP_CONNECTOR_TOKEN = CONNECTOR_TOKEN;
|
||||
// A state directory of its own, so the run can neither read nor leave a saved token.
|
||||
const STATE_DIR = await mkdtemp(join(tmpdir(), 'schulcloud-smoke-state-'));
|
||||
process.env.STATE_DIR = STATE_DIR;
|
||||
// And a notes directory of its own. The note tools are the only ones here that
|
||||
// write, so the run must not be able to touch real notes — and pointing them at
|
||||
// an empty directory is also the only way to assert the empty case.
|
||||
const NOTES_DIR = await mkdtemp(join(tmpdir(), 'schulcloud-smoke-notes-'));
|
||||
process.env.NOTES_DIR = NOTES_DIR;
|
||||
// A password of its own, so the web app is exercised and the run can never be
|
||||
// opened with one from the environment.
|
||||
const WEB_PASSWORD = `smoke-${randomBytes(16).toString('hex')}`;
|
||||
process.env.WEB_PASSWORD = WEB_PASSWORD;
|
||||
// The app is bound by this script on an ephemeral port, so config.port is unused.
|
||||
|
||||
const config = loadConfig();
|
||||
@@ -106,30 +126,88 @@ const taskId = tasks.text.match(/\(`([0-9a-f]{24})`\)/)?.[1];
|
||||
check('list_tasks finished', !(await call('list_tasks', { scope: 'finished' })).isError);
|
||||
check('list_news', !(await call('list_news')).isError);
|
||||
|
||||
// Walk courses until we find one with a board, to exercise the whole chain.
|
||||
// Walk courses collecting boards and lessons, to exercise the whole chain.
|
||||
// Every board id is collected rather than the first one taken: an unpublished
|
||||
// board is listed on the course page with its title but 403s when opened, so
|
||||
// "the first board in the course" is not reliably one that can be read.
|
||||
let boardId, fileId, lessonId, courseWithBoard;
|
||||
const boardIds = [];
|
||||
const topicsWithTasks = [];
|
||||
for (const id of courseIds) {
|
||||
const course = await call('get_course', { courseId: id });
|
||||
if (course.isError) continue;
|
||||
courseWithBoard ??= id;
|
||||
const b = course.text.match(/### Boards[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
|
||||
const l = course.text.match(/### Topics[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
|
||||
lessonId ??= l;
|
||||
if (b && !boardId) boardId = b;
|
||||
if (boardId && lessonId) break;
|
||||
const boardsSection = course.text.match(/### Boards[\s\S]*?(?=\n### |$)/)?.[0] ?? '';
|
||||
for (const m of boardsSection.matchAll(/\(`([0-9a-f]{24})`\)/g)) boardIds.push(m[1]);
|
||||
lessonId ??= course.text.match(/### Topics[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
|
||||
// A topic that reports tasks is the interesting one: those tasks are not task
|
||||
// elements on the course page and carry no id in the API.
|
||||
const topics = course.text.match(/### Topics[\s\S]*?(?=\n### |$)/)?.[0] ?? '';
|
||||
for (const m of topics.matchAll(/\(`([0-9a-f]{24})`\) — (\d+) task/g)) topicsWithTasks.push(m[1]);
|
||||
}
|
||||
check('get_course', Boolean(courseWithBoard), `first usable course ${courseWithBoard}`);
|
||||
check('found a column board', Boolean(boardId), boardId);
|
||||
|
||||
if (boardId) {
|
||||
const board = await call('get_board', { boardId });
|
||||
check('get_board', !board.isError && /Board id:/.test(board.text));
|
||||
if (courseWithBoard) {
|
||||
// The v3 course projection carries none of this; it comes from
|
||||
// /api/v1/courses, one of the three legacy routes the deployment still
|
||||
// publishes. Absent is acceptable — the route may be refused — but a course
|
||||
// that reports none of description, teachers or schedule means the legacy
|
||||
// lookup stopped working, which is worth knowing.
|
||||
const course = await call('get_course', { courseId: courseWithBoard });
|
||||
const enriched = /\*\*Taught by:\*\*|\*\*Members:\*\*|\*\*Weekly schedule:\*\*/.test(course.text);
|
||||
check('get_course reports course metadata beyond the v3 projection', enriched || true,
|
||||
enriched ? 'description/teachers/schedule present' : 'legacy course lookup returned nothing');
|
||||
}
|
||||
check('found a column board', boardIds.length > 0, `${boardIds.length} board(s)`);
|
||||
|
||||
let board, drafts = 0;
|
||||
for (const id of boardIds) {
|
||||
const attempt = await call('get_board', { boardId: id });
|
||||
if (!attempt.isError) {
|
||||
board = attempt;
|
||||
boardId = id;
|
||||
break;
|
||||
}
|
||||
if (/draft/i.test(attempt.text)) drafts++;
|
||||
}
|
||||
if (boardIds.length > 0) {
|
||||
check(
|
||||
'get_board',
|
||||
Boolean(board) && /Board id:/.test(board.text),
|
||||
boardId ? `opened ${boardId}${drafts ? `, skipped ${drafts} unpublished` : ''}` : 'no board could be opened',
|
||||
);
|
||||
}
|
||||
if (board) {
|
||||
// Pads carry real content and the board API returns them empty, so the text
|
||||
// comes from Etherpad itself. Either it was read, or the tool says plainly
|
||||
// that it was not — it must never claim the contents cannot be had.
|
||||
const padLine = board.text.match(/- Collaborative text document `[0-9a-f]{24}`[^\n]*/)?.[0];
|
||||
check(
|
||||
'collaborative text documents report contents or say they are empty',
|
||||
padLine === undefined || /:$|\(empty, or its contents could not be read\)/.test(padLine),
|
||||
padLine ?? 'no pad on this board',
|
||||
);
|
||||
fileId = board.text.match(/File: \*\*[^*]+\*\* \(`([0-9a-f]{24})`/)?.[1];
|
||||
check('get_board resolved attachments', Boolean(fileId), fileId ?? 'no files on this board');
|
||||
check('get_board includeFiles=false', !(await call('get_board', { boardId, includeFiles: false })).isError);
|
||||
}
|
||||
|
||||
if (lessonId) check('get_lesson', !(await call('get_lesson', { lessonId })).isError, lessonId);
|
||||
|
||||
// A task attached to a topic is reachable only if its id was recovered from the
|
||||
// topic page: the API's topic-task projection has no id field, and such a task
|
||||
// is on no course page and drops out of both task lists once it is past due.
|
||||
if (topicsWithTasks.length > 0) {
|
||||
const lesson = await call('get_lesson', { lessonId: topicsWithTasks[0] });
|
||||
const topicTaskId = lesson.text.match(/### Tasks in this lesson[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
|
||||
check('get_lesson lists a topic\'s tasks with ids', Boolean(topicTaskId), topicTaskId ?? lesson.text.slice(0, 90));
|
||||
if (topicTaskId) {
|
||||
const viaTopic = await call('get_task', { taskId: topicTaskId });
|
||||
check('get_task opens a task found only through a topic', !viaTopic.isError && /Task id:/.test(viaTopic.text));
|
||||
}
|
||||
} else {
|
||||
check('get_lesson lists a topic\'s tasks with ids', true, 'no topic on this account reports tasks — nothing to check');
|
||||
}
|
||||
if (taskId) {
|
||||
const task = await call('get_task', { taskId });
|
||||
check('get_task', !task.isError && /Task id:/.test(task.text), taskId);
|
||||
@@ -152,6 +230,238 @@ if (fileId) {
|
||||
check('download_file', false, 'no file id found to test with');
|
||||
}
|
||||
|
||||
console.log('\n== classes and groups ==');
|
||||
{
|
||||
// Classes are the only place membership is visible: courses report neither
|
||||
// their teachers nor their students, and a student may not resolve either
|
||||
// by user id. An account in no class is a legitimate answer.
|
||||
const classes = await call('list_classes', { includeGroups: true });
|
||||
check('list_classes responds', !classes.isError, classes.text.split('\n')[0]);
|
||||
check(
|
||||
'list_classes names teachers or says there are none',
|
||||
!classes.isError && (/taught by/.test(classes.text) || /not in any class/.test(classes.text) || /Groups \(/.test(classes.text)),
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n== file manager (Dateien) ==');
|
||||
// A separate store from files-storage, with a real folder tree. The account may
|
||||
// hold nothing there, so the checks find something rather than assume it —
|
||||
// but on an account whose courses do keep files, "nothing found" is a failure.
|
||||
{
|
||||
const root = await call('fs_list', { path: '/' });
|
||||
check('fs_list / names the four areas', !root.isError && /\/courses\//.test(root.text) && /\/shared\//.test(root.text));
|
||||
|
||||
const owners = await call('fs_list', { path: '/courses' });
|
||||
check('fs_list /courses lists course folders', !owners.isError, owners.text.split('\n').find((line) => /course\(s\)/.test(line)));
|
||||
const ownerIds = [...owners.text.matchAll(/\*\*.*?\/\*\* \(`([0-9a-f]{24})`\)/g)].map((m) => m[1]);
|
||||
|
||||
// The first course whose file area holds a file, at most ten listings in.
|
||||
let coursePath;
|
||||
let filePath;
|
||||
let fileName;
|
||||
let courseWithFiles;
|
||||
for (const id of ownerIds.slice(0, 10)) {
|
||||
const tree = await call('fs_tree', { path: `/courses/${id}`, depth: 3, maxFolders: 15 });
|
||||
if (tree.isError) continue;
|
||||
const heading = tree.text.match(/^## (\/courses\/.+?) — /m)?.[1];
|
||||
const line = tree.text.match(/^\s*([^\n]+?\.(?:pdf|docx|txt|png|jpg|xlsx|pptx|odt)) \(/im);
|
||||
if (heading && line) {
|
||||
coursePath = heading;
|
||||
courseWithFiles = id;
|
||||
fileName = line[1].trim();
|
||||
// The tree gives names; fs_find recovers the full path to read.
|
||||
const found = await call('fs_find', { name: fileName, path: `/courses/${id}`, type: 'file', maxFolders: 15 });
|
||||
filePath = found.text.match(/^- (\/courses\/.+?) — /m)?.[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (coursePath && filePath) {
|
||||
check('fs_tree shows a course file area', true, coursePath);
|
||||
check('fs_find finds a file by name', Boolean(filePath), filePath);
|
||||
const read = await call('fs_read', { path: filePath, maxChars: 800 });
|
||||
check(
|
||||
'fs_read fetches a file-manager file and reports an extraction outcome',
|
||||
!read.isError && /File id:/.test(read.text),
|
||||
read.text.split('\n').find((l) => /extracted|image|no extractable|image-only|Word|PDF/.test(l)) ?? fileName,
|
||||
);
|
||||
// A course whose teachers use only the file manager used to read as an
|
||||
// empty course page; get_course must now point at the files.
|
||||
const page = await call('get_course', { courseId: courseWithFiles });
|
||||
check('get_course points at the course files', !page.isError && /Course files \(Kurs-Dateien\)/.test(page.text));
|
||||
} else {
|
||||
check('fs_tree shows a course file area', true, 'no course among the first ten keeps files — nothing to check');
|
||||
}
|
||||
|
||||
const missing = await call('fs_list', { path: '/courses/__no such course__' });
|
||||
check('fs_list on a bad path is a tool error that says what is there', missing.isError && /No "|not a file area|Did you mean/.test(missing.text));
|
||||
|
||||
const shared = await call('fs_list', { path: '/Geteilte Dateien' });
|
||||
check('fs_list accepts the German area name', !shared.isError, shared.text.split('\n')[0]);
|
||||
}
|
||||
|
||||
console.log('\n== rooms ==');
|
||||
// Collected here and used by the H5P section below: a room's boards are where
|
||||
// this account's quiz lives.
|
||||
const roomBoardIds = [];
|
||||
// Rooms ("Räume") are a separate space from courses. An account in none is
|
||||
// normal — and is exactly the state that hid this whole feature — so the check
|
||||
// is that the tools answer sensibly either way, not that rooms exist.
|
||||
{
|
||||
const listed = await call('list_rooms');
|
||||
check('list_rooms responds', !listed.isError, listed.text.split('\n')[0]);
|
||||
const roomId = listed.text.match(/\(`([0-9a-f]{24})`\)/)?.[1];
|
||||
if (roomId) {
|
||||
const room = await call('get_room', { roomId });
|
||||
check('get_room opens a room', !room.isError && /Room id:/.test(room.text), roomId);
|
||||
roomBoardIds.push(...[...room.text.matchAll(/\(`([0-9a-f]{24})`\)/g)].map((m) => m[1]).slice(0, 8));
|
||||
const roomBoardId = room.text.match(/### Boards[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
|
||||
if (roomBoardId) {
|
||||
const board = await call('get_board', { boardId: roomBoardId });
|
||||
check('a room board opens with get_board', !board.isError && /in room/.test(board.text), roomBoardId);
|
||||
} else {
|
||||
check('a room board opens with get_board', true, 'the room has no boards');
|
||||
}
|
||||
} else {
|
||||
check('get_room opens a room', true, 'this account is in no rooms — nothing to open');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n== H5P exercises ==');
|
||||
// A quiz is an H5P element on a board, and Schulcloud has nothing else like it.
|
||||
// Which boards hold one is data, so the id is discovered by scanning the boards
|
||||
// this account can see — course boards first, then the rooms', which is where
|
||||
// this account's quiz actually lives.
|
||||
{
|
||||
let contentId;
|
||||
let foundOn;
|
||||
const boardsToScan = [...boardIds, ...roomBoardIds];
|
||||
for (const boardId of boardsToScan) {
|
||||
const board = await call('get_board', { boardId, includeFiles: false });
|
||||
const match = board.text.match(/content `([0-9a-f]{24})` — all of it with get_h5p/);
|
||||
if (match) {
|
||||
contentId = match[1];
|
||||
foundOn = boardId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (contentId) {
|
||||
check('get_board names an H5P exercise and its question count', true, `board ${foundOn}, content ${contentId}`);
|
||||
|
||||
const full = await call('get_h5p', { contentId });
|
||||
check(
|
||||
'get_h5p returns every question at once, with the solutions marked',
|
||||
!full.isError && /^### 1\. /m.test(full.text) && /✔/.test(full.text),
|
||||
full.text.split('\n')[0],
|
||||
);
|
||||
|
||||
const withheld = await call('get_h5p', { contentId, solutions: false });
|
||||
check(
|
||||
'solutions=false keeps the options and drops the answers',
|
||||
!withheld.isError && /^### 1\. /m.test(withheld.text) && !/✔/.test(withheld.text) && /Solutions withheld/.test(withheld.text),
|
||||
withheld.text.split('\n')[0],
|
||||
);
|
||||
} else {
|
||||
check('get_board names an H5P exercise and its question count', true, 'no board here holds one');
|
||||
}
|
||||
const missing = await call('get_h5p', { contentId: '000000000000000000000000' });
|
||||
check('an unknown content id is a tool error naming the id', missing.isError && /404/.test(missing.text), missing.text.split('\n')[0]);
|
||||
}
|
||||
|
||||
console.log('\n== resources and prompts ==');
|
||||
// Courses and rooms are resources a person attaches; the prompts are German
|
||||
// requests picked from a menu. Both reuse the tools' reads, so what is checked
|
||||
// here is the protocol surface, and the argument handling Claude Code forces
|
||||
// on prompts: it splits on whitespace, so words arrive joined with "_".
|
||||
{
|
||||
const { resources } = await client.listResources();
|
||||
const courseResources = resources.filter((r) => r.uri.startsWith('schulcloud://courses/'));
|
||||
const roomResources = resources.filter((r) => r.uri.startsWith('schulcloud://rooms/'));
|
||||
check('resources/list offers every course', courseResources.length === courseIds.length, `${courseResources.length} of ${courseIds.length}`);
|
||||
const roomsListed = await call('list_rooms');
|
||||
const roomCount = [...roomsListed.text.matchAll(/\(`([0-9a-f]{24})`\)/g)].length;
|
||||
check('resources/list offers every room', roomResources.length === roomCount, `${roomResources.length} of ${roomCount}`);
|
||||
// Claude Code's @ autocomplete shows the description instead of the name.
|
||||
check(
|
||||
'every resource description carries its name',
|
||||
resources.length > 0 && resources.every((r) => r.name && r.mimeType === 'text/markdown' && r.description?.endsWith(r.name)),
|
||||
);
|
||||
const { resourceTemplates } = await client.listResourceTemplates();
|
||||
check(
|
||||
'resource templates for courses and rooms',
|
||||
resourceTemplates.map((t) => t.uriTemplate).sort().join(' ') === 'schulcloud://courses/{courseId} schulcloud://rooms/{roomId}',
|
||||
);
|
||||
|
||||
if (courseWithBoard) {
|
||||
const read = await client.readResource({ uri: `schulcloud://courses/${courseWithBoard}` });
|
||||
const viaTool = await call('get_course', { courseId: courseWithBoard });
|
||||
check(
|
||||
'a course resource reads exactly as get_course',
|
||||
read.contents[0]?.mimeType === 'text/markdown' && read.contents[0]?.text === viaTool.text,
|
||||
read.contents[0]?.text?.split('\n')[0],
|
||||
);
|
||||
}
|
||||
if (roomResources[0]) {
|
||||
const room = await client.readResource({ uri: roomResources[0].uri });
|
||||
check('a room resource opens', /Room id:/.test(room.contents[0]?.text ?? ''), roomResources[0].uri);
|
||||
} else {
|
||||
check('a room resource opens', true, 'this account is in no rooms — nothing to open');
|
||||
}
|
||||
const unknownResource = await client
|
||||
.readResource({ uri: 'schulcloud://courses/000000000000000000000000' })
|
||||
.then(() => undefined, (error) => error);
|
||||
check(
|
||||
'an unknown course resource is a protocol error, not a crash',
|
||||
unknownResource !== undefined,
|
||||
unknownResource?.message?.split('\n')[0],
|
||||
);
|
||||
|
||||
const { prompts } = await client.listPrompts();
|
||||
check(
|
||||
'prompts listed',
|
||||
['pruefungsvorbereitung', 'zusammenfassung'].every((name) => prompts.some((p) => p.name === name)),
|
||||
prompts.map((p) => p.name).join(', '),
|
||||
);
|
||||
const courseTitle = courseWithBoard
|
||||
? courses.text.match(new RegExp(`- \\*\\*(.+?)\\*\\* \\(\`${courseWithBoard}\`\\)`))?.[1]
|
||||
: undefined;
|
||||
if (courseWithBoard && courseTitle) {
|
||||
const summary = await client.getPrompt({
|
||||
name: 'zusammenfassung',
|
||||
arguments: { kurs: courseTitle.split(/\s+/).join('_') },
|
||||
});
|
||||
const [embedded, instructions] = summary.messages;
|
||||
check(
|
||||
'zusammenfassung finds a course by its joined name and embeds its overview',
|
||||
embedded?.content.type === 'resource' && embedded.content.resource.uri === `schulcloud://courses/${courseWithBoard}`,
|
||||
courseTitle,
|
||||
);
|
||||
check(
|
||||
'zusammenfassung asks in German for the named course',
|
||||
instructions?.content.type === 'text' &&
|
||||
instructions.content.text.includes(`„${courseTitle}“`) &&
|
||||
/Antworte auf Deutsch/.test(instructions.content.text),
|
||||
);
|
||||
const exam = await client.getPrompt({
|
||||
name: 'pruefungsvorbereitung',
|
||||
arguments: { kurs: courseWithBoard, thema: 'Grundlagen_der_Programmierung', datum: '2026-10-02' },
|
||||
});
|
||||
const examText = exam.messages[1]?.content.type === 'text' ? exam.messages[1].content.text : '';
|
||||
check(
|
||||
'pruefungsvorbereitung takes an id, a joined topic and a date',
|
||||
/Thema der Prüfung: Grundlagen der Programmierung/.test(examText) && /Prüfungstermin: 2026-10-02/.test(examText),
|
||||
);
|
||||
}
|
||||
const refused = await client
|
||||
.getPrompt({ name: 'zusammenfassung', arguments: { kurs: 'kein_solcher_kurs_xyz' } })
|
||||
.then(() => undefined, (error) => error);
|
||||
check(
|
||||
'a prompt for an unknown course is refused, naming what exists',
|
||||
/Kein Kurs und kein Raum passt/.test(refused?.message ?? ''),
|
||||
refused?.message?.slice(0, 100),
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n== search ==');
|
||||
const searchTerm = process.env.SMOKE_SEARCH ?? 'Datenschutz';
|
||||
const search = await call('search', { query: searchTerm, fresh: true, courseId: courseIds[0] });
|
||||
@@ -167,9 +477,85 @@ if (taskId) {
|
||||
const subs = await call('list_submissions', { courseId: courseIds[0], scope: 'all' });
|
||||
check('list_submissions scoped to a course', !subs.isError, subs.text.split('\n')[0]);
|
||||
const all = await call('list_submissions', { limit: 5 });
|
||||
// Feedback and submitter names are the two things the status endpoint cannot
|
||||
// give: both come from the task's rendered page.
|
||||
const withFeedback = await call('list_submissions', { scope: 'all', includeFeedback: true, limit: 5 });
|
||||
check('list_submissions includeFeedback responds', !withFeedback.isError, withFeedback.text.split('\n')[0]);
|
||||
check(
|
||||
'list_submissions no longer defers feedback to get_task when asked for it',
|
||||
!withFeedback.isError && !/pass includeFeedback/.test(withFeedback.text),
|
||||
);
|
||||
|
||||
check('list_submissions unscoped', !all.isError, all.text.split('\n')[0]);
|
||||
}
|
||||
|
||||
console.log('\n== own notes ==');
|
||||
// The user's own lesson notes: the one store here that is neither Schulcloud's
|
||||
// nor WebUntis', and the one thing this server can write. Every check runs
|
||||
// against the throwaway NOTES_DIR above.
|
||||
{
|
||||
const noteTools = names.filter((name) => ['list_notes', 'get_note', 'add_note'].includes(name));
|
||||
check('the note tools are offered when NOTES_DIR is set', noteTools.length === 3, noteTools.join(', ') || 'none');
|
||||
|
||||
const empty = await call('list_notes');
|
||||
check(
|
||||
'an empty notes directory is explained, not reported as a failure',
|
||||
!empty.isError && /no notes yet/i.test(empty.text),
|
||||
empty.text.split('\n')[0],
|
||||
);
|
||||
|
||||
const added = await call('add_note', {
|
||||
title: 'Erörterung',
|
||||
text: 'Dreischritt: These, Argument, Fazit. Gegenargument nicht vergessen.',
|
||||
subject: 'Deutsch',
|
||||
date: '2026-09-15',
|
||||
tags: ['klausur'],
|
||||
});
|
||||
check('add_note saves a note', !added.isError && /Deutsch\/2026-09-15 Erörterung\.md/.test(added.text), added.text.split('\n')[0]);
|
||||
|
||||
const listed = await call('list_notes', { subject: 'deut' });
|
||||
check('list_notes finds it by a fragment of the subject', !listed.isError && /Erörterung/.test(listed.text), listed.text.split('\n')[0]);
|
||||
|
||||
const one = await call('get_note', { path: 'Deutsch/2026-09-15 Erörterung.md' });
|
||||
check('get_note returns the note in full', !one.isError && /Gegenargument/.test(one.text), one.text.split('\n')[0]);
|
||||
|
||||
const appended = await call('add_note', {
|
||||
title: 'Nachtrag',
|
||||
text: 'Beispiel: Handyverbot an Schulen.',
|
||||
subject: 'Deutsch',
|
||||
date: '2026-09-15',
|
||||
append: true,
|
||||
});
|
||||
const afterAppend = await call('get_note', { path: 'Deutsch/2026-09-15 Erörterung.md' });
|
||||
check(
|
||||
'append adds to the same note rather than starting a second one',
|
||||
!appended.isError && /Handyverbot/.test(afterAppend.text) && /Gegenargument/.test(afterAppend.text),
|
||||
appended.text.split('\n')[0],
|
||||
);
|
||||
|
||||
const missing = await call('get_note', { path: 'Deutsch/gibt-es-nicht.md' });
|
||||
check('a missing note is a tool error naming the path', missing.isError && /no note at/i.test(missing.text), missing.text.split('\n')[0]);
|
||||
|
||||
// The title reaches the filesystem, so it is untrusted input at exactly the
|
||||
// boundary core/paths.ts exists to guard.
|
||||
const hostile = await call('add_note', { title: '../../../etc/passwd', text: 'x', subject: '../..', date: '2026-09-15' });
|
||||
// The title is echoed back verbatim — it is the user's own — so the check is
|
||||
// on the path the note actually landed at, in backticks.
|
||||
const hostilePath = hostile.text.match(/`([^`]+)`/)?.[1] ?? '';
|
||||
check(
|
||||
'a note cannot be written outside the notes directory',
|
||||
!hostile.isError && hostilePath.length > 0 && !hostilePath.split('/').includes('..'),
|
||||
hostilePath,
|
||||
);
|
||||
|
||||
const fresh = await call('search', { query: 'Gegenargument', fresh: true, courseId: courseIds[0] });
|
||||
check(
|
||||
'a live search reads the notes too, so it agrees with the index',
|
||||
!fresh.isError && /Gegenargument/.test(fresh.text),
|
||||
fresh.text.split('\n')[0],
|
||||
);
|
||||
}
|
||||
|
||||
console.log('\n== index tools ==');
|
||||
// These degrade gracefully without DATABASE_URL, so assert on either outcome
|
||||
// rather than requiring a database for the smoke run to be meaningful.
|
||||
@@ -180,13 +566,274 @@ check(
|
||||
hasIndex ? !status.isError : status.isError && /not configured/.test(status.text),
|
||||
status.text.split('\n')[0],
|
||||
);
|
||||
const changed = await call('what_changed', { since: '2026-01-01' });
|
||||
check('what_changed responds', hasIndex ? !changed.isError : changed.isError);
|
||||
if (hasIndex) {
|
||||
// Populate before asking what changed: a brand-new index holds no generations
|
||||
// to diff, and what_changed rightly refuses rather than inventing a baseline.
|
||||
const refreshed = await call('refresh_index', { courseId: courseIds[0], force: true });
|
||||
check('refresh_index re-crawls one course', !refreshed.isError, refreshed.text.split('\n')[0]);
|
||||
}
|
||||
const changed = await call('what_changed', { since: '2026-01-01' });
|
||||
check('what_changed responds', hasIndex ? !changed.isError : changed.isError, changed.text.split('\n')[0]);
|
||||
if (hasIndex) {
|
||||
// Both the hit and the no-hit answer say when the index was last refreshed;
|
||||
// a live crawl (fresh=true) says nothing of the sort. That is what separates
|
||||
// "answered from the index" from "answered by crawling", whatever the term
|
||||
// happens to match in this account's data.
|
||||
const indexed = await call('search', { query: searchTerm });
|
||||
check('search uses the index and states freshness', !indexed.isError && /Index /.test(indexed.text));
|
||||
check(
|
||||
'search uses the index and states freshness',
|
||||
!indexed.isError && /refreshed/i.test(indexed.text),
|
||||
indexed.text.split('\n')[0],
|
||||
);
|
||||
|
||||
// Notes are only picked up by a *full* crawl, and a full crawl walks every
|
||||
// course and every file-manager folder — minutes, not seconds. Indexing them
|
||||
// is covered by test/store.test.ts against a real Postgres instead; what the
|
||||
// smoke checks here is that the kind filter exists and answers.
|
||||
const byKind = await call('search', { query: 'Gegenargument', kinds: ['note'] });
|
||||
check('search accepts the note kind', !byKind.isError, byKind.text.split('\n')[0]);
|
||||
}
|
||||
|
||||
console.log('\n== WebUntis ==');
|
||||
// The timetable lives in WebUntis, not in Schulcloud, and the tools only exist
|
||||
// when a key is configured. Both halves are asserted: with a key the live
|
||||
// answers have to be shaped right, without one the tools must not be offered at
|
||||
// all — a tool that can only fail is worse than a missing one.
|
||||
const hasUntis = Boolean(config.untis);
|
||||
{
|
||||
const untisTools = names.filter((name) => name.startsWith('untis_'));
|
||||
check(
|
||||
`untis_* tools are offered only with a key (${hasUntis ? 'configured' : 'not configured'})`,
|
||||
hasUntis
|
||||
? untisTools.join(' ') === 'untis_homework untis_lesson_topics untis_timetable'
|
||||
: untisTools.length === 0,
|
||||
untisTools.join(', ') || 'none',
|
||||
);
|
||||
}
|
||||
if (hasUntis) {
|
||||
const identity = await call('whoami');
|
||||
check(
|
||||
'whoami reports the WebUntis identity',
|
||||
/- WebUntis: /.test(identity.text) && !/not reachable/.test(identity.text),
|
||||
identity.text.split('\n').find((line) => line.startsWith('- WebUntis')),
|
||||
);
|
||||
|
||||
const today = await call('untis_timetable');
|
||||
check(
|
||||
'untis_timetable answers for today',
|
||||
!today.isError && /^## \p{L}+, \d{2}\.\d{2}\.\d{4}/mu.test(today.text),
|
||||
today.text.split('\n')[0],
|
||||
);
|
||||
|
||||
// A four-week window: either it holds lessons or the days say why not. The
|
||||
// school year has gaps — holidays, and the weeks this account spends at work —
|
||||
// so requiring lessons would make the run fail on a correct answer.
|
||||
const start = new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(Date.now() + 28 * 86_400_000).toISOString().slice(0, 10);
|
||||
const month = await call('untis_timetable', { from: start, to: end });
|
||||
const lessonLines = [...month.text.matchAll(/^- \d{2}:\d{2}–\d{2}:\d{2} /gm)].length;
|
||||
check(
|
||||
'untis_timetable answers for a four-week range',
|
||||
!month.isError && (lessonLines > 0 || /No lessons/.test(month.text)),
|
||||
`${lessonLines} lesson line(s)`,
|
||||
);
|
||||
|
||||
const changes = await call('untis_timetable', { from: start, to: end, changesOnly: true });
|
||||
check(
|
||||
'untis_timetable lists changes only',
|
||||
!changes.isError && (/\*\*(Entfall|Vertretung)\*\*/.test(changes.text) || /Nothing cancelled or changed/.test(changes.text)),
|
||||
changes.text.split('\n')[0],
|
||||
);
|
||||
|
||||
const homework = await call('untis_homework', { from: '2026-08-01', to: end });
|
||||
check('untis_homework answers', !homework.isError, homework.text.split('\n')[0]);
|
||||
|
||||
// Every lesson line carries its period id, which is the handle for the class
|
||||
// register. Without one there is nothing to ask about, so the check follows
|
||||
// the timetable's own output rather than a hard-coded id.
|
||||
const periodId = Number(month.text.match(/`(\d{5,})`/)?.[1]);
|
||||
if (Number.isInteger(periodId)) {
|
||||
const topics = await call('untis_lesson_topics', { periodId, limit: 3 });
|
||||
check(
|
||||
'untis_lesson_topics reads what previous lessons covered',
|
||||
!topics.isError && (/Unterrichtsinhalte/.test(topics.text) || /No lesson contents/.test(topics.text)),
|
||||
topics.text.split('\n')[0],
|
||||
);
|
||||
} else {
|
||||
check('untis_lesson_topics reads what previous lessons covered', true, 'skipped: no lesson in the window');
|
||||
}
|
||||
|
||||
// The subject form is the one that reconstructs a term without a period id.
|
||||
const subject = month.text.match(/\*\*([A-Za-zÄÖÜäöü0-9]{2,10})\*\*/)?.[1];
|
||||
if (subject) {
|
||||
const bySubject = await call('untis_lesson_topics', { subject, from: '2026-06-01', to: end, limit: 5 });
|
||||
check(
|
||||
`untis_lesson_topics reads a whole term by subject ("${subject}")`,
|
||||
!bySubject.isError && (/Unterricht „/.test(bySubject.text) || /No lessons of|nothing was recorded/.test(bySubject.text)),
|
||||
bySubject.text.split('\n')[0],
|
||||
);
|
||||
} else {
|
||||
check('untis_lesson_topics reads a whole term by subject', true, 'skipped: no subject in the window');
|
||||
}
|
||||
|
||||
const neither = await call('untis_lesson_topics', {});
|
||||
check(
|
||||
'untis_lesson_topics asks for a subject or a period, not neither',
|
||||
neither.isError && /subject/.test(neither.text),
|
||||
neither.text.split('\n')[0],
|
||||
);
|
||||
|
||||
const unreal = await call('untis_timetable', { from: '2026-02-30' });
|
||||
check(
|
||||
'a date that does not exist is refused rather than rolled over',
|
||||
unreal.isError && /Not a date in the calendar/.test(unreal.text),
|
||||
unreal.text.split('\n')[0],
|
||||
);
|
||||
|
||||
const huge = await call('untis_timetable', { from: start, to: '2027-06-30' });
|
||||
check(
|
||||
'an unreasonably long range is refused before it is fetched',
|
||||
huge.isError && /at most \d+ at a time/.test(huge.text),
|
||||
huge.text.split('\n')[0],
|
||||
);
|
||||
|
||||
const prep = await client.getPrompt({ name: 'tagesvorbereitung', arguments: { tag: '2026-09-21' } });
|
||||
check(
|
||||
'tagesvorbereitung attaches the timetable and asks in German',
|
||||
prep.messages.length === 2 &&
|
||||
/## Montag, 21\.09\.2026/.test(prep.messages[0].content.text) &&
|
||||
/Bereite mich auf den Schultag/.test(prep.messages[1].content.text),
|
||||
prep.description,
|
||||
);
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
// Every file the shell asks for, including the two modules the editor is
|
||||
// made of: a missing one leaves a page that loads and cannot type.
|
||||
const assetNames = ['app.js', 'editor.js', 'markdown.js', 'app.css', 'icon.svg', 'manifest.webmanifest'];
|
||||
const assets = await Promise.all(assetNames.map((name) => fetch(`${root}/app/${name}`)));
|
||||
check('the app\'s assets are served', assets.every((response) => response.ok), assets.map((r) => r.status).join(' '));
|
||||
check(
|
||||
'the editor\'s modules are served as JavaScript',
|
||||
assets
|
||||
.filter((_, index) => assetNames[index].endsWith('.js'))
|
||||
.every((response) => /javascript/.test(response.headers.get('content-type') ?? '')),
|
||||
assets.map((r) => r.headers.get('content-type')).join(' | '),
|
||||
);
|
||||
check(
|
||||
'the shell loads the app as a module, so its imports resolve',
|
||||
/<script type="module" src="app\.js">/.test(shellText) && shellText.includes('data-command="bold"'),
|
||||
);
|
||||
|
||||
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 ==');
|
||||
@@ -198,9 +845,107 @@ console.log('\n== error handling ==');
|
||||
const bogus = await call('get_course', { courseId: '000000000000000000000000' });
|
||||
check('unknown id returns a tool error, not a crash', bogus.isError, bogus.text.split('\n')[0]);
|
||||
|
||||
console.log('\n== connector token ==');
|
||||
// claude.ai sends a request header it stores, so the token in it opens /mcp and
|
||||
// nothing else: /api can replace the Schulcloud token and stream the mirror.
|
||||
{
|
||||
const root = `http://127.0.0.1:${port}`;
|
||||
const viaHeader = new Client({ name: 'smoke-connector', version: '0' }, { capabilities: {} });
|
||||
await viaHeader.connect(
|
||||
new StreamableHTTPClientTransport(new URL(`${root}/mcp`), {
|
||||
requestInit: { headers: { authorization: `Bearer ${CONNECTOR_TOKEN}` } },
|
||||
}),
|
||||
);
|
||||
const connectorTools = await viaHeader.listTools();
|
||||
check('the connector token opens /mcp', connectorTools.tools.length === tools.length, `${connectorTools.tools.length} tools`);
|
||||
await viaHeader.close();
|
||||
|
||||
const asApiKey = await fetch(`${root}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'x-api-key': CONNECTOR_TOKEN },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'smoke-api-key', version: '0' } },
|
||||
}),
|
||||
});
|
||||
check('the connector token works as x-api-key too', asApiKey.ok, `got ${asApiKey.status}`);
|
||||
await asApiKey.body?.cancel();
|
||||
|
||||
const onApi = await fetch(`${root}/api/token`, { headers: { authorization: `Bearer ${CONNECTOR_TOKEN}` } });
|
||||
check('the connector token is refused on /api', onApi.status === 401, `got ${onApi.status}`);
|
||||
}
|
||||
|
||||
console.log('\n== secret path and session token ==');
|
||||
// claude.ai's connector dialog takes only a URL, so /<secret>/mcp serves MCP
|
||||
// without a bearer token; and the Schulcloud token can be replaced at runtime.
|
||||
// Nothing here replaces the live token: the one PUT that succeeds sends the
|
||||
// token already in use, which the server answers without a swap.
|
||||
{
|
||||
const root = `http://127.0.0.1:${port}`;
|
||||
const wrong = await fetch(`${root}/${'f'.repeat(64)}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
|
||||
});
|
||||
check('a wrong path secret looks like any unknown path (404)', wrong.status === 404, `got ${wrong.status}`);
|
||||
|
||||
const viaSecret = new Client({ name: 'smoke-secret-path', version: '0' }, { capabilities: {} });
|
||||
await viaSecret.connect(new StreamableHTTPClientTransport(new URL(`${root}/${PATH_SECRET}/mcp`)));
|
||||
const secretTools = await viaSecret.listTools();
|
||||
check('the secret path serves MCP without a bearer token', secretTools.tools.length === tools.length, `${secretTools.tools.length} tools`);
|
||||
await viaSecret.close();
|
||||
|
||||
const anonymous = await fetch(`${root}/api/token`);
|
||||
check('/api/token needs the bearer token', anonymous.status === 401, `got ${anonymous.status}`);
|
||||
|
||||
const bearer = { authorization: `Bearer ${TOKEN}` };
|
||||
const statusResponse = await fetch(`${root}/api/token`, { headers: bearer });
|
||||
const statusText = await statusResponse.text();
|
||||
const tokenStatus = JSON.parse(statusText);
|
||||
check(
|
||||
'/api/token reports the expiry and never the token',
|
||||
statusResponse.ok && typeof tokenStatus.expiresAt === 'string' && Number.isInteger(tokenStatus.daysLeft) && !statusText.includes(config.jwt),
|
||||
`${tokenStatus.daysLeft} day(s) left, from ${tokenStatus.source}`,
|
||||
);
|
||||
|
||||
const jwtInUse = config.jwt;
|
||||
const malformed = await fetch(`${root}/api/token`, {
|
||||
method: 'PUT',
|
||||
headers: { ...bearer, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jwt: 'not-a-token' }),
|
||||
});
|
||||
const refusal = await malformed.json();
|
||||
check(
|
||||
'a malformed token is refused and the one in use stays',
|
||||
malformed.status === 422 && refusal.error === 'malformed' && config.jwt === jwtInUse,
|
||||
`${malformed.status} ${refusal.error}`,
|
||||
);
|
||||
|
||||
const same = await fetch(`${root}/api/token`, {
|
||||
method: 'PUT',
|
||||
headers: { ...bearer, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jwt: `jwt=${jwtInUse};` }),
|
||||
});
|
||||
const sameResult = await same.json();
|
||||
check('the token already in use is accepted without a swap', same.ok && sameResult.changed === false, `${same.status}`);
|
||||
|
||||
const page = await fetch(`${root}/token`);
|
||||
const script = await fetch(`${root}/token.js`);
|
||||
check(
|
||||
'/token page is served with a strict content security policy',
|
||||
page.ok && /text\/html/.test(page.headers.get('content-type') ?? '') &&
|
||||
/default-src 'none'/.test(page.headers.get('content-security-policy') ?? '') &&
|
||||
script.ok && /javascript/.test(script.headers.get('content-type') ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
await client.close();
|
||||
httpServer.close();
|
||||
await closeServices(services);
|
||||
await rm(STATE_DIR, { recursive: true, force: true });
|
||||
await rm(NOTES_DIR, { recursive: true, force: true });
|
||||
|
||||
console.log(`\n${results.length - failures}/${results.length} checks passed`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
|
||||
216
src/bin/cli.ts
216
src/bin/cli.ts
@@ -4,9 +4,12 @@ import { mkdir } from 'node:fs/promises';
|
||||
import { basename, dirname, resolve } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { ApiClient, ApiError } from '../cli/client.ts';
|
||||
import { ApiClient, ApiError, type TokenInfo } from '../cli/client.ts';
|
||||
import { readHidden, readPiped } from '../cli/prompt.ts';
|
||||
import { defaultSyncDir, loadCliConfig, saveCliConfig, configPath } from '../cli/config.ts';
|
||||
import { formatBytes } from '../core/extract.ts';
|
||||
import { fsFind, fsGet, fsList, fsTree } from '../cli/fs.ts';
|
||||
import { noteAdd, noteImport, noteList, noteShow } from '../cli/notes.ts';
|
||||
import { sync, type SyncEvent } from '../cli/sync.ts';
|
||||
|
||||
/**
|
||||
@@ -25,6 +28,32 @@ const USAGE = `schulcloud — browse and mirror your Schulcloud files
|
||||
schulcloud get <fileId> [--out <path>]
|
||||
schulcloud sync [--dry-run] [--full] [--prune] [--dir <path>] [--jobs <n>]
|
||||
schulcloud refresh [--course <id>] [--force]
|
||||
schulcloud token when the server's Schulcloud token expires
|
||||
schulcloud token set hand the server a fresh one (paste, or pipe it in)
|
||||
|
||||
The file manager ("Dateien") — /my, /courses/<course>, /teams/<team>, /shared:
|
||||
|
||||
schulcloud fs ls [path] [--long]
|
||||
schulcloud fs tree [path] [--depth <n>] [--max-folders <n>]
|
||||
schulcloud fs find <name> [--path <path>] [--type file|folder] [--long]
|
||||
schulcloud fs get <path> [--out <path>] [--force] [--jobs <n>]
|
||||
|
||||
fs get downloads a file, or a folder with everything below it. Names may contain
|
||||
"/" and still resolve; any path segment can also be an id from "fs ls --long".
|
||||
|
||||
Your own lesson notes — Markdown files the agents read as context:
|
||||
|
||||
schulcloud note ls [--subject <name>] [--since <date>] [--until <date>] [--long]
|
||||
schulcloud note show <path>
|
||||
schulcloud note add --title <title> [--subject <name>] [--date <date>]
|
||||
[--tags a,b] [--append] text on stdin, or --text
|
||||
schulcloud note import <export.ndjson> [--subject <name>] [--out <dir>] [--dry-run]
|
||||
|
||||
note import takes the file scripts/export-apple-notes.js writes on a Mac; see
|
||||
docs/NOTES.md. --out writes the Markdown locally instead of sending it.
|
||||
|
||||
--course takes a course or a room id: rooms ("Räume") mirror alongside courses
|
||||
and their files sit under the room's name.
|
||||
|
||||
Options are also read from SCHULCLOUD_SERVER, SCHULCLOUD_TOKEN and
|
||||
SCHULCLOUD_SYNC_DIR. Config file: ${configPath()}
|
||||
@@ -55,6 +84,13 @@ async function main(argv: string[]): Promise<number> {
|
||||
return runSync(flags);
|
||||
case 'refresh':
|
||||
return refresh(flags);
|
||||
case 'fs':
|
||||
return fileManager(flags);
|
||||
case 'note':
|
||||
case 'notes':
|
||||
return notes(flags);
|
||||
case 'token':
|
||||
return token(flags);
|
||||
default:
|
||||
process.stderr.write(`Unknown command "${command}".\n\n${USAGE}`);
|
||||
return 2;
|
||||
@@ -107,6 +143,8 @@ async function list(flags: Flags): Promise<number> {
|
||||
const api = new ApiClient(await loadCliConfig());
|
||||
const manifest = await api.manifest();
|
||||
let entries = manifest.entries.filter((entry) => entry.status !== 'removed');
|
||||
// A room id works here too: the manifest's courseId is the container id, and
|
||||
// since rooms were added that container can be a room.
|
||||
if (flags.course) entries = entries.filter((entry) => entry.courseId === flags.course);
|
||||
|
||||
if (entries.length === 0) {
|
||||
@@ -149,6 +187,122 @@ async function get(flags: Flags): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function fileManager(flags: Flags): Promise<number> {
|
||||
const [sub, ...args] = flags._ as string[];
|
||||
const api = new ApiClient(await loadCliConfig());
|
||||
const out = (line: string) => process.stdout.write(`${line}\n`);
|
||||
const long = Boolean(flags.long);
|
||||
switch (sub) {
|
||||
case 'ls':
|
||||
return fsList(api, args[0] ?? '/', long, out);
|
||||
case 'tree':
|
||||
return fsTree(api, args[0] ?? '/', Number(flags.depth ?? 3), Number(flags['max-folders'] ?? 200), out);
|
||||
case 'find': {
|
||||
if (!args[0]) {
|
||||
process.stderr.write('fs find needs a name, e.g.: schulcloud fs find Erbrecht --path /courses\n');
|
||||
return 2;
|
||||
}
|
||||
const type = flags.type === 'folder' || flags.type === 'file' ? String(flags.type) : 'any';
|
||||
return fsFind(api, args[0], String(flags.path ?? '/'), type, Number(flags['max-folders'] ?? 400), long, out);
|
||||
}
|
||||
case 'get':
|
||||
if (!args[0]) {
|
||||
process.stderr.write('fs get needs a path, e.g.: schulcloud fs get "/courses/<course>/<folder>"\n');
|
||||
return 2;
|
||||
}
|
||||
return fsGet(
|
||||
api,
|
||||
args[0],
|
||||
{ out: flags.out ? String(flags.out) : undefined, force: Boolean(flags.force), jobs: Number(flags.jobs ?? 3) },
|
||||
out,
|
||||
);
|
||||
default:
|
||||
process.stderr.write(`Unknown fs command "${sub ?? ''}". Use ls, tree, find or get.\n\n${USAGE}`);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
async function notes(flags: Flags): Promise<number> {
|
||||
const [sub, ...args] = flags._ as string[];
|
||||
const out = (line: string) => process.stdout.write(`${line}\n`);
|
||||
|
||||
// `--out` writes files directly, which is the one note command that needs no
|
||||
// server: a migration should be runnable and inspectable before anything is
|
||||
// sent anywhere.
|
||||
const offlineImport = sub === 'import' && Boolean(flags.out);
|
||||
const api = offlineImport ? undefined : new ApiClient(await loadCliConfig());
|
||||
|
||||
switch (sub) {
|
||||
case 'ls':
|
||||
case 'list':
|
||||
return noteList(
|
||||
api!,
|
||||
{
|
||||
...(flags.subject ? { subject: String(flags.subject) } : {}),
|
||||
...(flags.since ? { since: String(flags.since) } : {}),
|
||||
...(flags.until ? { until: String(flags.until) } : {}),
|
||||
},
|
||||
Boolean(flags.long),
|
||||
out,
|
||||
);
|
||||
case 'show':
|
||||
case 'cat':
|
||||
if (!args[0]) {
|
||||
process.stderr.write('note show needs a path, e.g.: schulcloud note show "Deutsch/2026-09-15 Erörterung.md"\n');
|
||||
return 2;
|
||||
}
|
||||
return noteShow(api!, args[0], out);
|
||||
case 'add': {
|
||||
const title = flags.title ? String(flags.title) : args[0];
|
||||
if (!title) {
|
||||
process.stderr.write('note add needs --title.\n');
|
||||
return 2;
|
||||
}
|
||||
// Piped text is the normal way in: it is how a note gets here from an
|
||||
// editor, a clipboard or another command. Typing it straight in works
|
||||
// too, but only if we say how it ends.
|
||||
if (!flags.text && process.stdin.isTTY) {
|
||||
process.stderr.write('Type the note, then Ctrl-D to save (Ctrl-C to abort):\n');
|
||||
}
|
||||
const body = flags.text ? String(flags.text) : await readPiped();
|
||||
if (!body?.trim()) {
|
||||
process.stderr.write('note add needs the note text: pass --text, or pipe it in.\n');
|
||||
return 2;
|
||||
}
|
||||
return noteAdd(
|
||||
api!,
|
||||
{
|
||||
title,
|
||||
text: body,
|
||||
...(flags.subject ? { subject: String(flags.subject) } : {}),
|
||||
...(flags.date ? { date: String(flags.date) } : {}),
|
||||
...(flags.tags ? { tags: String(flags.tags).split(',').map((tag) => tag.trim()).filter(Boolean) } : {}),
|
||||
append: Boolean(flags.append),
|
||||
},
|
||||
out,
|
||||
);
|
||||
}
|
||||
case 'import':
|
||||
if (!args[0]) {
|
||||
process.stderr.write('note import needs the export file, e.g.: schulcloud note import notes.ndjson\n');
|
||||
return 2;
|
||||
}
|
||||
return noteImport(
|
||||
api,
|
||||
args[0],
|
||||
{
|
||||
...(flags.out ? { outDir: resolve(String(flags.out)) } : {}),
|
||||
...(flags.subject ? { subject: String(flags.subject) } : {}),
|
||||
dryRun: Boolean(flags['dry-run']),
|
||||
},
|
||||
out,
|
||||
);
|
||||
default:
|
||||
process.stderr.write(`Unknown note command "${sub ?? ''}". Use ls, show, add or import.\n\n${USAGE}`);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
async function runSync(flags: Flags): Promise<number> {
|
||||
const config = await loadCliConfig();
|
||||
const root = flags.dir ? resolve(String(flags.dir)) : config.syncDir;
|
||||
@@ -183,7 +337,14 @@ async function refresh(flags: Flags): Promise<number> {
|
||||
const api = new ApiClient(await loadCliConfig());
|
||||
const scope = flags.course ? String(flags.course) : undefined;
|
||||
process.stderr.write(`Asking the server to re-crawl ${scope ? `course ${scope}` : 'everything'}…\n`);
|
||||
const result = (await api.refresh(scope, Boolean(flags.force))) as {
|
||||
let lastNote = 0;
|
||||
const result = (await api.refresh(scope, Boolean(flags.force), (seconds) => {
|
||||
// A note every half minute, so a long first crawl does not look hung.
|
||||
if (seconds - lastNote >= 30) {
|
||||
lastNote = seconds;
|
||||
process.stderr.write(` still crawling… ${seconds}s\n`);
|
||||
}
|
||||
})) as {
|
||||
crawlId: number; courses: number; files: number; mirrored: number; extracted: number;
|
||||
skipped: number; durationMs: number; joined?: boolean;
|
||||
};
|
||||
@@ -196,6 +357,57 @@ async function refresh(flags: Flags): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The monthly chore: log in to Schulcloud in a private window, copy the `jwt`
|
||||
* cookie, paste it here, close the window. The server checks the token with
|
||||
* Schulcloud before swapping it in, so a bad paste changes nothing.
|
||||
*/
|
||||
async function token(flags: Flags): Promise<number> {
|
||||
const api = new ApiClient(await loadCliConfig());
|
||||
const sub = flags._[0];
|
||||
|
||||
if (sub === undefined || sub === 'status') {
|
||||
process.stdout.write(`${describeToken(await api.token())}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (sub !== 'set') {
|
||||
process.stderr.write(`Unknown token command "${sub}". Use "schulcloud token" or "schulcloud token set".\n`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const pasted = process.stdin.isTTY
|
||||
? await readHidden('Paste the value of the "jwt" cookie (input hidden): ')
|
||||
: await readPiped();
|
||||
if (!pasted.trim()) {
|
||||
process.stderr.write('No token given.\n');
|
||||
return 2;
|
||||
}
|
||||
process.stderr.write('Checking it with Schulcloud…\n');
|
||||
const result = await api.replaceToken(pasted);
|
||||
process.stdout.write(`${result.changed ? 'Replaced' : 'Already in use'}: ${describeToken(result)}\n`);
|
||||
if (result.changed && !result.persisted) {
|
||||
process.stderr.write('Not saved on the server (STATE_DIR is unset): a restart falls back to TSC_JWT_COOKIE.\n');
|
||||
}
|
||||
process.stdout.write('Now close the private window — left open, it logs this token out about two hours after login.\n');
|
||||
return 0;
|
||||
}
|
||||
|
||||
function describeToken(info: TokenInfo): string {
|
||||
const expiry = info.expiresAt
|
||||
? `expires ${info.expiresAt.slice(0, 16).replace('T', ' ')} UTC (${info.daysLeft} day(s) left)`
|
||||
: 'expiry unknown';
|
||||
const keepalive = info.keepalive;
|
||||
const session = !keepalive
|
||||
? 'keepalive off'
|
||||
: keepalive.running
|
||||
? `session alive${keepalive.budgetSeconds === undefined ? '' : `, ${Math.round(keepalive.budgetSeconds / 60)} min budget`}`
|
||||
: 'session ENDED — Schulcloud rejected the token; run: schulcloud token set';
|
||||
const source =
|
||||
info.source === 'environment' ? 'from TSC_JWT_COOKIE' : info.source === 'state file' ? 'saved from an earlier replacement' : info.source;
|
||||
const warning = info.daysLeft !== undefined && info.daysLeft <= 7 ? '\nRenew it soon: schulcloud token set' : '';
|
||||
return `${expiry}; ${session}; ${source}${warning}`;
|
||||
}
|
||||
|
||||
function describe(event: SyncEvent, dryRun: boolean): string {
|
||||
switch (event.type) {
|
||||
case 'download':
|
||||
|
||||
@@ -20,7 +20,25 @@ async function main(): Promise<void> {
|
||||
console.log(message),
|
||||
)
|
||||
: undefined;
|
||||
services.keepalive = keepalive;
|
||||
keepalive?.start();
|
||||
// A replaced token is a new session to hold, and very likely the fix for a
|
||||
// keepalive that stopped on a 401.
|
||||
services.session.onReplaced(() => keepalive?.restart());
|
||||
|
||||
// The token's 30 days end on a date nothing else announces, and the only fix
|
||||
// needs a person at a browser — so warn a week ahead, twice a day.
|
||||
const warnIfExpiring = () => {
|
||||
const { daysLeft } = services.session.status();
|
||||
if (daysLeft === undefined || daysLeft > 7) return;
|
||||
console.log(
|
||||
daysLeft < 0
|
||||
? '[schulcloud-mcp] session token: EXPIRED. Replace it with `schulcloud token set` or on the /token page.'
|
||||
: `[schulcloud-mcp] session token: expires in ${daysLeft} day(s). Replace it with \`schulcloud token set\` or on the /token page.`,
|
||||
);
|
||||
};
|
||||
warnIfExpiring();
|
||||
setInterval(warnIfExpiring, 12 * 60 * 60_000).unref();
|
||||
|
||||
// Periodic re-crawl so the index does not drift. Each run only downloads
|
||||
// files it has never seen, so a steady state costs a few hundred cheap GETs.
|
||||
@@ -49,11 +67,18 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const server = app.listen(config.port, config.bindHost, () => {
|
||||
const token = services.session.status();
|
||||
console.log(
|
||||
`[schulcloud-mcp] listening on ${config.bindHost}:${config.port} — instance ${config.baseUrl}, ` +
|
||||
`auth ${config.authToken ? 'enabled' : 'DISABLED'}, ` +
|
||||
`auth ${config.authToken ? 'enabled' : 'DISABLED'}` +
|
||||
`${config.connectorToken ? ' (plus connector token)' : ''}${config.mcpPathSecret ? ' (plus secret MCP path)' : ''}, ` +
|
||||
`token from ${token.source}${token.daysLeft === undefined ? '' : `, ${token.daysLeft} day(s) left`}` +
|
||||
`${token.persistent ? '' : ' (replacements not saved: STATE_DIR unset)'}, ` +
|
||||
`keepalive ${keepalive ? `every ${Math.round(config.keepaliveIntervalMs / 60_000)}min` : 'off'}, ` +
|
||||
`index ${services.store ? (config.crawlIntervalMs > 0 ? `every ${Math.round(config.crawlIntervalMs / 3_600_000)}h` : 'on demand') : 'off'}`,
|
||||
`index ${services.store ? (config.crawlIntervalMs > 0 ? `every ${Math.round(config.crawlIntervalMs / 3_600_000)}h` : 'on demand') : 'off'}, ` +
|
||||
// The origin, never the key: this is the line that says whether the
|
||||
// timetable tools exist at all in this deployment.
|
||||
`untis ${services.untis ? services.untis.origin : 'off'}`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
190
src/cli/apple-notes.ts
Normal file
190
src/cli/apple-notes.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { decodeEntities } from '../core/text.ts';
|
||||
|
||||
/**
|
||||
* Turning an Apple Notes export into notes this server can read.
|
||||
*
|
||||
* Notes.app stores a note's body as HTML and exposes it through AppleScript,
|
||||
* which is the only interface it has — there is no file on disk to copy, no
|
||||
* export format worth the name, and iCloud's copy is encrypted. So
|
||||
* `scripts/export-apple-notes.js` reads the notes through that interface and
|
||||
* writes one JSON object per line; this converts them.
|
||||
*
|
||||
* Kept out of `core/` because nothing on the server needs it: a migration runs
|
||||
* once, from the Mac that has the notes, and the server only ever sees the
|
||||
* Markdown that comes out.
|
||||
*/
|
||||
|
||||
/** One note as `scripts/export-apple-notes.js` writes it. */
|
||||
export interface AppleNote {
|
||||
id: string;
|
||||
name: string;
|
||||
/** The note's HTML body. */
|
||||
body: string;
|
||||
/** The Notes folder it sits in — "Notizen", "Deutsch", "Schule/LF07". */
|
||||
folder?: string;
|
||||
/** ISO timestamps from Notes.app. */
|
||||
created?: string;
|
||||
modified?: string;
|
||||
/** Set when Notes refused the body, e.g. a locked note. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ConvertedNote {
|
||||
title: string;
|
||||
text: string;
|
||||
date?: string;
|
||||
subject?: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An exported note as a note here.
|
||||
*
|
||||
* The creation date becomes the note's date because that is the day of the
|
||||
* lesson it was taken in — the modification date is whenever it was last
|
||||
* tidied, which is not a school day at all.
|
||||
*/
|
||||
export function convertAppleNote(note: AppleNote, options: { subject?: string } = {}): ConvertedNote {
|
||||
const body = htmlToMarkdown(note.body);
|
||||
const title = (note.name || firstLine(body) || 'Notiz').trim();
|
||||
// Notes repeats the title as the first line of the body; keeping both would
|
||||
// give every migrated note a duplicated heading.
|
||||
const text = stripLeadingTitle(body, title);
|
||||
return {
|
||||
title,
|
||||
text,
|
||||
...(dayOf(note.created) ? { date: dayOf(note.created)! } : {}),
|
||||
...(subjectFor(note, options.subject) ? { subject: subjectFor(note, options.subject)! } : {}),
|
||||
source: 'apple-notes',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The subject a note belongs to: what the caller said, else its Notes folder.
|
||||
*
|
||||
* The folder is the only structure Notes has, and someone keeping lesson notes
|
||||
* has almost certainly used it for exactly this. A note loose in the default
|
||||
* folder gets no subject rather than a wrong one.
|
||||
*/
|
||||
function subjectFor(note: AppleNote, override: string | undefined): string | undefined {
|
||||
if (override) return override;
|
||||
const folder = note.folder?.trim();
|
||||
if (!folder) return undefined;
|
||||
// Notes' own default folders say nothing about a subject.
|
||||
if (/^(notes|notizen|alle .*|all .*|recently deleted|zuletzt gelöscht)$/i.test(folder)) return undefined;
|
||||
// A nested folder arrives as "Schule/Deutsch"; the leaf is the subject.
|
||||
return folder.split('/').pop()!.trim() || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apple Notes HTML as Markdown.
|
||||
*
|
||||
* Deliberately small. Notes emits a narrow set of tags — divs, breaks, lists,
|
||||
* headings, bold/italic/underline, links and tables — and the goal is readable
|
||||
* text that keeps its structure, not a faithful rendering. Anything unknown
|
||||
* loses its tag and keeps its words, which is the right failure for a note.
|
||||
*/
|
||||
export function htmlToMarkdown(html: string | undefined | null): string {
|
||||
if (!html) return '';
|
||||
let value = html;
|
||||
|
||||
// Drop what carries no text at all before anything else looks at it.
|
||||
value = value.replace(/<(script|style|head)[^>]*>[\s\S]*?<\/\1>/gi, '');
|
||||
// Attachments (images, scans, drawings) come through as <object>: they have
|
||||
// no text, and silently dropping them would hide that the note had one.
|
||||
value = value.replace(/<object\b[^>]*>[\s\S]*?<\/object>/gi, '\n[Anhang aus Apple Notes — nicht übernommen]\n');
|
||||
value = value.replace(/<img\b[^>]*>/gi, '\n[Bild aus Apple Notes — nicht übernommen]\n');
|
||||
|
||||
value = value.replace(/<br\s*\/?>/gi, '\n');
|
||||
// `li` is deliberately absent: the next `<li>` already opens a line, and
|
||||
// closing one here too would put a blank line between every bullet, which
|
||||
// Markdown renders as a loose list.
|
||||
value = value.replace(/<\/(p|div|tr|h[1-6]|blockquote)>/gi, '\n');
|
||||
|
||||
value = value.replace(/<h([1-6])[^>]*>/gi, (_all, level: string) => `\n${'#'.repeat(Number(level))} `);
|
||||
// A checklist is a list in Notes and a task list in Markdown; the checked
|
||||
// state lives on the li, so it has to be read before the tag is stripped.
|
||||
value = value.replace(/<li\b[^>]*\bchecked\b[^>]*>/gi, '\n- [x] ');
|
||||
value = value.replace(/<li[^>]*>/gi, '\n- ');
|
||||
// A blank line after a list, or whatever follows is absorbed into the last
|
||||
// bullet as a lazy continuation.
|
||||
value = value.replace(/<\/(ul|ol|table)>/gi, '\n\n');
|
||||
value = value.replace(/<(b|strong)>([\s\S]*?)<\/\1>/gi, (_all, _tag, inner: string) => emphasise(inner, '**'));
|
||||
value = value.replace(/<(i|em)>([\s\S]*?)<\/\1>/gi, (_all, _tag, inner: string) => emphasise(inner, '_'));
|
||||
value = value.replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_all, href: string, inner: string) => {
|
||||
const label = stripTags(inner).trim();
|
||||
return label ? `[${label}](${href})` : href;
|
||||
});
|
||||
// Table cells become separators rather than vanishing, or a row of figures
|
||||
// runs into one number.
|
||||
value = value.replace(/<\/(td|th)>/gi, ' | ');
|
||||
|
||||
value = stripTags(value);
|
||||
value = decodeEntities(value);
|
||||
|
||||
return value
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/[ \t ]+/g, ' ').replace(/ \| $/, '').trimEnd())
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Emphasis only around text that has some: `** **` renders as literal stars. */
|
||||
function emphasise(inner: string, marker: string): string {
|
||||
const text = inner.replace(/<br\s*\/?>/gi, '\n');
|
||||
const body = stripTags(text).trim();
|
||||
if (!body) return '';
|
||||
return `${marker}${body}${marker}`;
|
||||
}
|
||||
|
||||
function stripTags(value: string): string {
|
||||
return value.replace(/<[^>]+>/g, '');
|
||||
}
|
||||
|
||||
function firstLine(body: string): string | undefined {
|
||||
return body
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/^#+\s*/, '').trim())
|
||||
.find((line) => line.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the title if the body repeats it.
|
||||
*
|
||||
* Notes shows a note's first line as its name, so `name` and the first line of
|
||||
* `body` are usually the same string.
|
||||
*/
|
||||
function stripLeadingTitle(body: string, title: string): string {
|
||||
const lines = body.split('\n');
|
||||
const firstIndex = lines.findIndex((line) => line.trim().length > 0);
|
||||
if (firstIndex === -1) return '';
|
||||
const first = lines[firstIndex]!.replace(/^#+\s*/, '').replace(/^\*\*(.*)\*\*$/, '$1').trim();
|
||||
if (first !== title.trim()) return body.trim();
|
||||
return lines.slice(firstIndex + 1).join('\n').trim();
|
||||
}
|
||||
|
||||
/** An ISO timestamp as a school day, or nothing when Notes gave none. */
|
||||
function dayOf(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const at = new Date(value);
|
||||
if (Number.isNaN(at.getTime())) return undefined;
|
||||
// The export writes local time, which is the timezone the note was taken in.
|
||||
return value.slice(0, 10).match(/^\d{4}-\d{2}-\d{2}$/) ? value.slice(0, 10) : at.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Parses the export file: one JSON object per line, blank lines ignored. */
|
||||
export function parseExport(contents: string): AppleNote[] {
|
||||
const notes: AppleNote[] = [];
|
||||
for (const [index, line] of contents.split('\n').entries()) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
notes.push(JSON.parse(trimmed) as AppleNote);
|
||||
} catch {
|
||||
// One unparsable line must not cost the export; say which.
|
||||
throw new Error(`Line ${index + 1} of the export is not JSON. Re-run scripts/export-apple-notes.js.`);
|
||||
}
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
@@ -26,6 +26,80 @@ export interface Manifest {
|
||||
entries: ManifestEntry[];
|
||||
}
|
||||
|
||||
/** One entry of a file-manager tree or search, as /api/fs returns it. */
|
||||
export interface FsEntry {
|
||||
type: 'directory' | 'file';
|
||||
path: string;
|
||||
parentPath: string;
|
||||
depth: number;
|
||||
id: string;
|
||||
name: string;
|
||||
size?: number;
|
||||
mimeType?: string | null;
|
||||
blocked?: boolean;
|
||||
}
|
||||
|
||||
export interface FsListing {
|
||||
path: string;
|
||||
kind: 'directory' | 'file';
|
||||
area?: string | null;
|
||||
directories?: { id: string; name: string; path: string }[];
|
||||
files?: { id: string; name: string; path: string; size: number; mimeType?: string; blocked: boolean }[];
|
||||
file?: { id: string; name: string; size: number; mimeType?: string; blocked: boolean };
|
||||
}
|
||||
|
||||
export interface FsWalk {
|
||||
path: string;
|
||||
kind: 'directory' | 'file';
|
||||
entries?: FsEntry[];
|
||||
matches?: FsEntry[];
|
||||
file?: FsListing['file'];
|
||||
visited?: number;
|
||||
truncated?: boolean;
|
||||
failures?: { path: string; reason: string }[];
|
||||
}
|
||||
|
||||
/** One of the user's own notes, as `/api/notes` reports it. A listing omits `text`. */
|
||||
export interface NoteSummary {
|
||||
path: string;
|
||||
title: string;
|
||||
date?: string;
|
||||
subject?: string;
|
||||
courseId?: string;
|
||||
tags: string[];
|
||||
source?: string;
|
||||
modifiedAt: string;
|
||||
bytes: number;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface NoteListing {
|
||||
root: string;
|
||||
writable: boolean;
|
||||
count: number;
|
||||
notes: NoteSummary[];
|
||||
}
|
||||
|
||||
export interface NoteInputPayload {
|
||||
title: string;
|
||||
text: string;
|
||||
date?: string;
|
||||
subject?: string;
|
||||
courseId?: string;
|
||||
tags?: string[];
|
||||
source?: string;
|
||||
append?: boolean;
|
||||
}
|
||||
|
||||
/** The server's Schulcloud token, as `/api/token` reports it — never the token itself. */
|
||||
export interface TokenInfo {
|
||||
expiresAt?: string;
|
||||
daysLeft?: number;
|
||||
source: string;
|
||||
persistent: boolean;
|
||||
keepalive: { running: boolean; budgetSeconds?: number; lastExtendedAt?: string; rejectedAt?: string } | null;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
@@ -65,30 +139,120 @@ export class ApiClient {
|
||||
return (await (await this.request('/api/status')).json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async token(): Promise<TokenInfo> {
|
||||
return (await (await this.request('/api/token')).json()) as TokenInfo;
|
||||
}
|
||||
|
||||
/** Hands the server a fresh Schulcloud token; it checks the token before using it. */
|
||||
async replaceToken(jwt: string): Promise<TokenInfo & { changed: boolean; persisted: boolean }> {
|
||||
const response = await this.request('/api/token', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jwt }),
|
||||
});
|
||||
return (await response.json()) as TokenInfo & { changed: boolean; persisted: boolean };
|
||||
}
|
||||
|
||||
async manifest(since?: string): Promise<Manifest> {
|
||||
const query = since ? `?since=${encodeURIComponent(since)}` : '';
|
||||
return (await (await this.request(`/api/manifest${query}`)).json()) as Manifest;
|
||||
}
|
||||
|
||||
async refresh(courseId?: string, force = false): Promise<Record<string, unknown>> {
|
||||
/**
|
||||
* Starts a re-crawl and waits for it by polling the server's status.
|
||||
*
|
||||
* Not one long request: a crawl that downloads every course file can run for
|
||||
* many minutes, and fetch gives up after five without response headers —
|
||||
* which reported "fetch failed" for a crawl that was succeeding.
|
||||
*/
|
||||
async refresh(
|
||||
courseId?: string,
|
||||
force = false,
|
||||
onWaiting?: (seconds: number) => void,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const response = await this.request('/api/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ courseId, force }),
|
||||
body: JSON.stringify({ courseId, force, wait: false }),
|
||||
});
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
const started = (await response.json()) as { joined?: boolean; startedAt?: string | null };
|
||||
const began = Date.now();
|
||||
|
||||
for (;;) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
const status = (await this.status()) as {
|
||||
indexer?: { running?: boolean; lastResult?: Record<string, unknown>; lastError?: string } | null;
|
||||
};
|
||||
const indexer = status.indexer;
|
||||
if (!indexer) throw new ApiError(503, 'The server has no indexer.');
|
||||
if (indexer.running) {
|
||||
onWaiting?.(Math.round((Date.now() - began) / 1000));
|
||||
continue;
|
||||
}
|
||||
if (indexer.lastError) throw new ApiError(502, `The re-crawl failed on the server: ${indexer.lastError}`);
|
||||
if (!indexer.lastResult) throw new ApiError(502, 'The re-crawl finished without a result.');
|
||||
return { ...indexer.lastResult, joined: started.joined === true };
|
||||
}
|
||||
}
|
||||
|
||||
/** Streams one file's bytes. */
|
||||
async file(fileId: string): Promise<Response> {
|
||||
return this.request(`/api/files/${encodeURIComponent(fileId)}`);
|
||||
}
|
||||
|
||||
// --- the file manager ------------------------------------------------------
|
||||
|
||||
async fsList(path: string): Promise<FsListing> {
|
||||
return (await (await this.request(`/api/fs/list?${new URLSearchParams({ path })}`)).json()) as FsListing;
|
||||
}
|
||||
|
||||
async fsTree(path: string, depth: number, maxFolders: number): Promise<FsWalk> {
|
||||
const query = new URLSearchParams({ path, depth: String(depth), maxFolders: String(maxFolders) });
|
||||
return (await (await this.request(`/api/fs/tree?${query}`)).json()) as FsWalk;
|
||||
}
|
||||
|
||||
async fsFind(name: string, path: string, type: string, maxFolders: number): Promise<FsWalk> {
|
||||
const query = new URLSearchParams({ name, path, type, maxFolders: String(maxFolders) });
|
||||
return (await (await this.request(`/api/fs/find?${query}`)).json()) as FsWalk;
|
||||
}
|
||||
|
||||
// --- the user's own notes --------------------------------------------------
|
||||
|
||||
async notes(filter: { subject?: string; since?: string; until?: string; limit?: number } = {}): Promise<NoteListing> {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filter)) if (value !== undefined) query.set(key, String(value));
|
||||
const suffix = query.toString() ? `?${query}` : '';
|
||||
return (await (await this.request(`/api/notes${suffix}`)).json()) as NoteListing;
|
||||
}
|
||||
|
||||
async note(path: string): Promise<NoteSummary> {
|
||||
return (await (await this.request(`/api/notes?${new URLSearchParams({ path })}`)).json()) as NoteSummary;
|
||||
}
|
||||
|
||||
async addNote(input: NoteInputPayload): Promise<NoteSummary & { appended: boolean }> {
|
||||
const response = await this.request('/api/notes', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return (await response.json()) as NoteSummary & { appended: boolean };
|
||||
}
|
||||
|
||||
/** Streams one file-manager file's bytes, by path or by id. */
|
||||
async fsFile(target: { path: string } | { id: string; name: string }): Promise<Response> {
|
||||
const query = 'path' in target ? new URLSearchParams({ path: target.path }) : new URLSearchParams(target);
|
||||
return this.request(`/api/fs/file?${query}`);
|
||||
}
|
||||
}
|
||||
|
||||
function describe(status: number, detail: string, server: string): string {
|
||||
if (status === 401) return `Unauthorized — the token is wrong or expired. Re-run: schulcloud login --server ${server} --token <token>`;
|
||||
// The notes routes have their own 503, and it already says what to do.
|
||||
if (status === 503 && /NOTES_DIR/.test(detail)) return detail;
|
||||
if (status === 503) return 'The server is running without an index, so this command is unavailable. Set DATABASE_URL on the server.';
|
||||
if (status === 409) return detail || 'The sync cursor is unknown to the server. Run a full sync with --full.';
|
||||
if (status === 429) return detail || 'Refreshed too recently — wait a moment, or pass --force.';
|
||||
// The file manager's own errors already say what was not found and what is there.
|
||||
if ((status === 400 || status === 404 || status === 422) && detail) return detail;
|
||||
return detail ? `HTTP ${status}: ${detail}` : `HTTP ${status}`;
|
||||
}
|
||||
|
||||
218
src/cli/fs.ts
Normal file
218
src/cli/fs.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { mkdir, rename, stat, unlink } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { formatBytes } from '../core/extract.ts';
|
||||
import { resolveWithin, safeComponent } from '../core/paths.ts';
|
||||
import type { ApiClient, FsEntry } from './client.ts';
|
||||
|
||||
/**
|
||||
* `schulcloud fs` — the file manager ("Dateien") from the command line.
|
||||
*
|
||||
* The same tree the MCP fs_* tools show: /my, /courses/<course>, /teams/<team>,
|
||||
* /shared. Everything goes through the server's /api/fs routes; nothing here
|
||||
* talks to Schulcloud.
|
||||
*/
|
||||
|
||||
type Out = (line: string) => void;
|
||||
|
||||
export async function fsList(api: ApiClient, path: string, long: boolean, out: Out): Promise<number> {
|
||||
const listing = await api.fsList(path);
|
||||
if (listing.kind === 'file' && listing.file) {
|
||||
out(`${listing.path}`);
|
||||
out(` ${formatBytes(listing.file.size)} ${listing.file.mimeType ?? 'unknown type'} ${listing.file.id}${listing.file.blocked ? ' [blocked]' : ''}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const directories = listing.directories ?? [];
|
||||
const files = listing.files ?? [];
|
||||
out(listing.path);
|
||||
for (const directory of directories) out(` ${directory.name}/${long ? ` ${directory.id}` : ''}`);
|
||||
for (const file of files) {
|
||||
const detail = long ? ` ${formatBytes(file.size).padStart(9)} ${file.id}${file.mimeType ? ` ${file.mimeType}` : ''}` : '';
|
||||
out(` ${file.name}${detail}${file.blocked ? ' [blocked]' : ''}`);
|
||||
}
|
||||
if (directories.length + files.length === 0) out(' (empty)');
|
||||
out(`${directories.length} folder(s), ${files.length} file(s)`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function fsTree(api: ApiClient, path: string, depth: number, maxFolders: number, out: Out): Promise<number> {
|
||||
const walk = await api.fsTree(path, depth, maxFolders);
|
||||
if (walk.kind === 'file') {
|
||||
out(`${walk.path} is a file.`);
|
||||
return 0;
|
||||
}
|
||||
const entries = walk.entries ?? [];
|
||||
const children = groupByParent(entries);
|
||||
|
||||
out(walk.path);
|
||||
let files = 0;
|
||||
let bytes = 0;
|
||||
const visit = (parent: string, prefix: string) => {
|
||||
const kids = children.get(parent) ?? [];
|
||||
kids.forEach((kid, index) => {
|
||||
const last = index === kids.length - 1;
|
||||
const branch = last ? '└── ' : '├── ';
|
||||
if (kid.type === 'directory') {
|
||||
out(`${prefix}${branch}${kid.name}/`);
|
||||
visit(kid.path, `${prefix}${last ? ' ' : '│ '}`);
|
||||
} else {
|
||||
files++;
|
||||
bytes += kid.size ?? 0;
|
||||
out(`${prefix}${branch}${kid.name} (${formatBytes(kid.size ?? 0)})${kid.blocked ? ' [blocked]' : ''}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
visit(walk.path, '');
|
||||
|
||||
out(`\n${files} file(s), ${formatBytes(bytes)} — ${walk.visited ?? 0} folder(s) listed`);
|
||||
if (walk.truncated) out(`Stopped after ${maxFolders} folders; pass --max-folders or start deeper.`);
|
||||
for (const failure of walk.failures ?? []) out(`could not list ${failure.path}: ${failure.reason}`);
|
||||
return walk.failures?.length ? 1 : 0;
|
||||
}
|
||||
|
||||
export async function fsFind(
|
||||
api: ApiClient,
|
||||
name: string,
|
||||
path: string,
|
||||
type: string,
|
||||
maxFolders: number,
|
||||
long: boolean,
|
||||
out: Out,
|
||||
): Promise<number> {
|
||||
const result = await api.fsFind(name, path, type, maxFolders);
|
||||
for (const match of result.matches ?? []) {
|
||||
const suffix = match.type === 'directory' ? '/' : '';
|
||||
const detail = long && match.type === 'file' ? ` ${formatBytes(match.size ?? 0)} ${match.id}` : long ? ` ${match.id}` : '';
|
||||
out(`${match.path}${suffix}${detail}`);
|
||||
}
|
||||
const count = result.matches?.length ?? 0;
|
||||
process.stderr.write(`${count} match(es), ${result.visited ?? 0} folder(s) searched\n`);
|
||||
if (result.truncated) process.stderr.write(`Stopped after ${maxFolders} folders; there may be more. Narrow --path.\n`);
|
||||
return count > 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a file, or a whole folder recursively.
|
||||
*
|
||||
* A folder lands under `--out` (default: a directory named after it) with the
|
||||
* file manager's structure. Every component is a name from Schulcloud and so
|
||||
* untrusted: each goes through `safeComponent`, and the joined path through
|
||||
* `resolveWithin`, exactly as sync does.
|
||||
*/
|
||||
export async function fsGet(
|
||||
api: ApiClient,
|
||||
path: string,
|
||||
options: { out?: string; force: boolean; jobs: number },
|
||||
out: Out,
|
||||
): Promise<number> {
|
||||
const target = await api.fsList(path);
|
||||
|
||||
if (target.kind === 'file' && target.file) {
|
||||
const destination = options.out ? resolve(options.out) : resolve(safeComponent(target.file.name, target.file.id));
|
||||
if (target.file.blocked) {
|
||||
process.stderr.write(`${target.path}: blocked by the instance virus scanner; not downloaded.\n`);
|
||||
return 1;
|
||||
}
|
||||
await downloadTo(api, { path: target.path }, destination);
|
||||
out(destination);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rootName = target.path === '/' ? 'Dateien' : (target.path.split('/').pop() ?? 'Dateien');
|
||||
const root = options.out ? resolve(options.out) : resolve(safeComponent(rootName, 'Dateien'));
|
||||
process.stderr.write(`Listing ${target.path} …\n`);
|
||||
const walk = await api.fsTree(target.path, 12, 1000);
|
||||
const entries = walk.entries ?? [];
|
||||
|
||||
// Rebuild each file's name segments from its parents rather than splitting
|
||||
// its path: names may contain "/", which would otherwise invent folders.
|
||||
const segments = new Map<string, string[]>([[walk.path, []]]);
|
||||
for (const entry of [...entries].sort((a, b) => a.depth - b.depth)) {
|
||||
const parent = segments.get(entry.parentPath);
|
||||
if (parent) segments.set(entry.path, [...parent, entry.name]);
|
||||
}
|
||||
|
||||
const files = entries.filter((entry) => entry.type === 'file');
|
||||
let downloaded = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
let bytes = 0;
|
||||
|
||||
const queue = [...files];
|
||||
const worker = async () => {
|
||||
for (let entry = queue.shift(); entry; entry = queue.shift()) {
|
||||
const parts = segments.get(entry.path);
|
||||
if (!parts) continue;
|
||||
const relative = parts.map((part) => safeComponent(part)).join('/');
|
||||
const destination = resolveWithin(root, relative);
|
||||
if (entry.blocked) {
|
||||
process.stderr.write(` blocked ${relative}\n`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
// Unchanged by size: re-running a folder download resumes rather than repeats.
|
||||
const existing = await stat(destination).catch(() => undefined);
|
||||
if (!options.force && existing?.isFile() && existing.size === entry.size) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await downloadTo(api, { id: entry.id, name: entry.name }, destination);
|
||||
downloaded++;
|
||||
bytes += entry.size ?? 0;
|
||||
process.stderr.write(` get ${relative}\n`);
|
||||
} catch (error) {
|
||||
failed++;
|
||||
process.stderr.write(` FAILED ${relative}: ${(error as Error).message}\n`);
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.max(1, options.jobs) }, worker));
|
||||
|
||||
out(root);
|
||||
process.stderr.write(
|
||||
`Downloaded ${downloaded} file(s) (${formatBytes(bytes)}), ${skipped} skipped` +
|
||||
`${failed ? `, FAILED ${failed}` : ''} — ${walk.visited ?? 0} folder(s) listed\n`,
|
||||
);
|
||||
if (walk.truncated) process.stderr.write('The folder is larger than one listing pass; some files were not reached.\n');
|
||||
for (const failure of walk.failures ?? []) process.stderr.write(`could not list ${failure.path}: ${failure.reason}\n`);
|
||||
return failed > 0 || (walk.failures?.length ?? 0) > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
async function downloadTo(api: ApiClient, target: { path: string } | { id: string; name: string }, destination: string) {
|
||||
const response = await api.fsFile(target);
|
||||
if (!response.body) throw new Error('empty response body');
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
// A temporary neighbour, renamed into place, so an interrupted download never
|
||||
// leaves a half-file that the size check would later accept as complete.
|
||||
const temp = `${destination}.part`;
|
||||
try {
|
||||
await pipeline(Readable.fromWeb(response.body as never), createWriteStream(temp));
|
||||
await rename(temp, destination);
|
||||
} catch (error) {
|
||||
await unlink(temp).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function groupByParent(entries: FsEntry[]): Map<string, FsEntry[]> {
|
||||
const children = new Map<string, FsEntry[]>();
|
||||
for (const entry of entries) {
|
||||
const list = children.get(entry.parentPath) ?? [];
|
||||
list.push(entry);
|
||||
children.set(entry.parentPath, list);
|
||||
}
|
||||
for (const list of children.values()) {
|
||||
list.sort((a, b) =>
|
||||
a.type !== b.type
|
||||
? a.type === 'directory'
|
||||
? -1
|
||||
: 1
|
||||
: a.name.localeCompare(b.name, 'de', { numeric: true, sensitivity: 'base' }),
|
||||
);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
143
src/cli/notes.ts
Normal file
143
src/cli/notes.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { convertAppleNote, parseExport } from './apple-notes.ts';
|
||||
import type { ApiClient, NoteSummary } from './client.ts';
|
||||
import { writeNote } from '../core/notes.ts';
|
||||
|
||||
/**
|
||||
* `schulcloud note` — the user's own lesson notes from the command line.
|
||||
*
|
||||
* The notes live on the server beside the index, so these go through /api like
|
||||
* everything else here. The exception is `import --out`, which writes files
|
||||
* directly: a migration of several hundred notes is worth doing offline, and
|
||||
* the result can be looked at before it goes anywhere.
|
||||
*/
|
||||
|
||||
export interface NoteWriter {
|
||||
(line: string): void;
|
||||
}
|
||||
|
||||
export async function noteList(
|
||||
api: ApiClient,
|
||||
filter: { subject?: string; since?: string; until?: string },
|
||||
long: boolean,
|
||||
out: NoteWriter,
|
||||
): Promise<number> {
|
||||
const listing = await api.notes(filter);
|
||||
if (listing.count === 0) {
|
||||
out(`No notes yet. The server keeps them in ${listing.root}.`);
|
||||
return 0;
|
||||
}
|
||||
for (const note of listing.notes) out(formatLine(note, long));
|
||||
if (listing.notes.length < listing.count) {
|
||||
out(`… ${listing.count - listing.notes.length} more.`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function noteShow(api: ApiClient, path: string, out: NoteWriter): Promise<number> {
|
||||
const note = await api.note(path);
|
||||
out(`# ${note.title}`);
|
||||
const facts = [note.date, note.subject, note.tags.length > 0 ? note.tags.join(', ') : undefined].filter(Boolean);
|
||||
if (facts.length > 0) out(facts.join(' · '));
|
||||
out('');
|
||||
out(note.text ?? '');
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function noteAdd(
|
||||
api: ApiClient,
|
||||
input: { title: string; text: string; subject?: string; date?: string; tags?: string[]; append?: boolean },
|
||||
out: NoteWriter,
|
||||
): Promise<number> {
|
||||
const note = await api.addNote({ ...input, source: 'cli' });
|
||||
out(`${note.appended ? 'Appended to' : 'Saved'} ${note.path}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export interface ImportOptions {
|
||||
/** Write files here instead of sending them to the server. */
|
||||
outDir?: string;
|
||||
/** Force every note into one subject, rather than using its Notes folder. */
|
||||
subject?: string;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates an Apple Notes export.
|
||||
*
|
||||
* Notes with no text are skipped rather than imported empty: an export always
|
||||
* has some — locked notes, and notes that are one attachment — and a store
|
||||
* seeded with blank entries makes every later listing worse.
|
||||
*/
|
||||
export async function noteImport(
|
||||
api: ApiClient | undefined,
|
||||
file: string,
|
||||
options: ImportOptions,
|
||||
out: NoteWriter,
|
||||
): Promise<number> {
|
||||
const contents = await readFile(resolve(file), 'utf8');
|
||||
const exported = parseExport(contents);
|
||||
if (exported.length === 0) {
|
||||
out(`${file} holds no notes. Re-run scripts/export-apple-notes.js on the Mac.`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let empty = 0;
|
||||
let failed = 0;
|
||||
const unreadable: string[] = [];
|
||||
|
||||
for (const note of exported) {
|
||||
if (note.error) {
|
||||
unreadable.push(note.name || note.id);
|
||||
continue;
|
||||
}
|
||||
const converted = convertAppleNote(note, options.subject ? { subject: options.subject } : {});
|
||||
if (!converted.text.trim()) {
|
||||
empty++;
|
||||
continue;
|
||||
}
|
||||
if (options.dryRun) {
|
||||
out(`would import: ${converted.date ?? '????-??-??'} · ${converted.subject ?? '—'} · ${converted.title}`);
|
||||
imported++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (options.outDir) {
|
||||
const { note: written } = await writeNote(options.outDir, converted);
|
||||
out(written.path);
|
||||
} else {
|
||||
if (!api) throw new Error('No server configured and no --out directory given.');
|
||||
const written = await api.addNote(converted);
|
||||
out(written.path);
|
||||
}
|
||||
imported++;
|
||||
} catch (error) {
|
||||
failed++;
|
||||
out(`FAILED ${converted.title}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
out(
|
||||
`${options.dryRun ? 'Would import' : 'Imported'} ${imported} of ${exported.length} note(s)` +
|
||||
(empty > 0 ? `, skipped ${empty} with no text` : '') +
|
||||
(unreadable.length > 0 ? `, ${unreadable.length} unreadable in Notes` : '') +
|
||||
(failed > 0 ? `, FAILED ${failed}` : '') +
|
||||
'.',
|
||||
);
|
||||
if (unreadable.length > 0) {
|
||||
// Almost always locked notes: they are the ones worth naming, because the
|
||||
// fix is to unlock them in Notes and export again.
|
||||
out(`Unreadable (locked, or not downloaded from iCloud): ${unreadable.slice(0, 10).join('; ')}`);
|
||||
}
|
||||
return failed > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
function formatLine(note: NoteSummary, long: boolean): string {
|
||||
const date = note.date ?? ' ';
|
||||
const subject = note.subject ? `[${note.subject}] ` : '';
|
||||
if (!long) return `${date} ${subject}${note.title}`;
|
||||
const tags = note.tags.length > 0 ? ` #${note.tags.join(' #')}` : '';
|
||||
return `${date} ${String(note.bytes).padStart(7)} ${subject}${note.title}${tags}\n ${note.path}`;
|
||||
}
|
||||
53
src/cli/prompt.ts
Normal file
53
src/cli/prompt.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Reading a secret the user pastes.
|
||||
*
|
||||
* The Schulcloud token grants read access to the whole account, so it is never
|
||||
* a command-line argument (shell history) and never echoed (scrollback).
|
||||
*/
|
||||
|
||||
/** A line typed or pasted at the terminal, not echoed. */
|
||||
export function readHidden(prompt: string): Promise<string> {
|
||||
const input = process.stdin;
|
||||
return new Promise((resolve, reject) => {
|
||||
let value = '';
|
||||
const finish = () => {
|
||||
input.off('data', onData);
|
||||
input.setRawMode(false);
|
||||
input.pause();
|
||||
process.stderr.write('\n');
|
||||
};
|
||||
const onData = (chunk: string) => {
|
||||
for (const char of chunk) {
|
||||
if (char === '\r' || char === '\n') {
|
||||
finish();
|
||||
// A terminal with bracketed paste on wraps a paste in markers.
|
||||
resolve(value.replace(/\[20[01]~/g, ''));
|
||||
return;
|
||||
}
|
||||
if (char === '' || char === '') {
|
||||
finish();
|
||||
reject(new Error('Cancelled.'));
|
||||
return;
|
||||
}
|
||||
if (char === '' || char === '\b') {
|
||||
value = value.slice(0, -1);
|
||||
continue;
|
||||
}
|
||||
value += char;
|
||||
}
|
||||
};
|
||||
process.stderr.write(prompt);
|
||||
input.setRawMode(true);
|
||||
input.setEncoding('utf8');
|
||||
input.resume();
|
||||
input.on('data', onData);
|
||||
});
|
||||
}
|
||||
|
||||
/** Everything piped in, for `wl-paste | schulcloud token set` and the like. */
|
||||
export async function readPiped(): Promise<string> {
|
||||
let data = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
for await (const chunk of process.stdin) data += chunk;
|
||||
return data;
|
||||
}
|
||||
194
src/config.ts
194
src/config.ts
@@ -1,18 +1,41 @@
|
||||
/**
|
||||
* Runtime configuration, read once from the environment.
|
||||
* Runtime configuration, read once from the environment — except `jwt`, which
|
||||
* can be replaced while the server runs.
|
||||
*
|
||||
* The two Schulcloud values are named after the browser artefacts they come
|
||||
* from (`TSC_URL`, `TSC_JWT_COOKIE`) so that copying a fresh token out of
|
||||
* DevTools stays an obvious, mechanical step — see docs/AUTH.md.
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path';
|
||||
import type { UntisConfig } from './core/untis.ts';
|
||||
|
||||
export interface Config {
|
||||
/** Instance base URL, no trailing slash, e.g. `https://schulcloud-thueringen.de`. */
|
||||
baseUrl: string;
|
||||
/** Raw JWT from the instance's `jwt` cookie. Sent as `Authorization: Bearer`. */
|
||||
/**
|
||||
* Raw JWT from the instance's `jwt` cookie. Sent as `Authorization: Bearer`.
|
||||
* Replaced at runtime by core/session-token.ts, so read it at the moment of
|
||||
* use and never keep a copy.
|
||||
*/
|
||||
jwt: string;
|
||||
/** Shared secret callers must present to this MCP server. Unused in stdio mode. */
|
||||
authToken: string | undefined;
|
||||
/**
|
||||
* A second token, accepted on `/mcp` only: the one claude.ai's connector
|
||||
* sends as a request header. It is stored by a third party, so it opens the
|
||||
* read-only MCP tools and not `/api`, which can replace the session token
|
||||
* and stream the file mirror — and it can be revoked on its own.
|
||||
*/
|
||||
connectorToken: string | undefined;
|
||||
/**
|
||||
* Serves MCP at `/<secret>/mcp` without a bearer token, for clients that can
|
||||
* send none — claude.ai's connector dialog takes only a URL. The path is then
|
||||
* the credential, so it must never be logged.
|
||||
*/
|
||||
mcpPathSecret: string | undefined;
|
||||
/** Where state that must survive a restart is kept: a replaced session token. Unset = memory only. */
|
||||
stateDir: string | undefined;
|
||||
port: number;
|
||||
bindHost: string;
|
||||
/** Hard ceiling on how many bytes `download_file` will pull from the instance. */
|
||||
@@ -33,8 +56,47 @@ export interface Config {
|
||||
mirrorDir: string;
|
||||
/** Files larger than this are indexed as metadata but not mirrored. */
|
||||
mirrorMaxBytes: number;
|
||||
/** Index personal files and submitted/returned work as well as course content. */
|
||||
indexPersonalFiles: boolean;
|
||||
/** Walk the file manager (Kurs-, Persönliche, Team- and Geteilte Dateien) when crawling. */
|
||||
indexFileManager: boolean;
|
||||
/** How often to re-crawl on a timer. Zero = only on demand. */
|
||||
crawlIntervalMs: number;
|
||||
|
||||
/**
|
||||
* Password for the web app at `/app` — the notes editor and the settings
|
||||
* page. Unset = the app is not served at all, by the same rule the untis_*
|
||||
* tools follow: a login screen no password can open is worse than no page.
|
||||
*
|
||||
* A credential, and the only one here a person types: it is hashed at
|
||||
* startup and the plain value is never compared, stored or logged.
|
||||
*/
|
||||
webPassword: string | undefined;
|
||||
/**
|
||||
* Where the user's own lesson notes live, as Markdown files. Unset = the
|
||||
* note tools are not offered, the same rule the untis_* tools follow.
|
||||
*/
|
||||
notesDir: string | undefined;
|
||||
/**
|
||||
* Whether add_note may write. The notes directory is the only thing in this
|
||||
* server anything can write to, so turning it off is a real setting and not
|
||||
* a theoretical one — a deployment that syncs its notes in from elsewhere
|
||||
* wants the files left alone.
|
||||
*/
|
||||
notesWritable: boolean;
|
||||
/**
|
||||
* How far back to read the WebUntis class register into the index. Zero =
|
||||
* not at all. Costs one timetable call per 90 days plus one per lesson
|
||||
* series, so a school year is a few dozen requests on a background crawl.
|
||||
*/
|
||||
untisHistoryDays: number;
|
||||
|
||||
/**
|
||||
* WebUntis, where the school keeps the timetable. Unset = the untis_* tools
|
||||
* are not offered at all, which is the right answer for a school that does
|
||||
* not use it — Schulcloud alone cannot say what happens when.
|
||||
*/
|
||||
untis: UntisConfig | undefined;
|
||||
}
|
||||
|
||||
function required(name: string): string {
|
||||
@@ -43,6 +105,13 @@ function required(name: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** `1`, `true`, `yes` and `on` are all true; anything else falls back. */
|
||||
function bool(name: string, fallback: boolean): boolean {
|
||||
const raw = process.env[name]?.trim().toLowerCase();
|
||||
if (!raw) return fallback;
|
||||
return ['1', 'true', 'yes', 'on'].includes(raw);
|
||||
}
|
||||
|
||||
function int(name: string, fallback: number): number {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) return fallback;
|
||||
@@ -53,6 +122,94 @@ function int(name: string, fallback: number): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** A URL-safe secret of at least 32 characters, or undefined when unset. */
|
||||
function pathSecret(name: string): string | undefined {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) return undefined;
|
||||
// The value is a credential: the error states the rule and never echoes it.
|
||||
if (!/^[A-Za-z0-9_-]{32,}$/.test(value)) {
|
||||
throw new Error(
|
||||
`Environment variable ${name} must be at least 32 characters of A-Z, a-z, 0-9, "-" or "_". ` +
|
||||
'Generate one with: openssl rand -hex 32',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** A token of at least 32 characters without whitespace, or undefined when unset. */
|
||||
function secretToken(name: string): string | undefined {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) return undefined;
|
||||
// A credential: the error states the rule and never echoes the value.
|
||||
if (value.length < 32 || /\s/.test(value)) {
|
||||
throw new Error(
|
||||
`Environment variable ${name} must be at least 32 characters without spaces. ` +
|
||||
'Generate one with: openssl rand -hex 32',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The four WebUntis values, or undefined when none is set.
|
||||
*
|
||||
* All four or nothing: three of them are harmless identifiers and the fourth is
|
||||
* a credential, so a half-filled block is a copy-paste that went wrong, not a
|
||||
* configuration to guess at. They come from one dialog — WebUntis → Profil →
|
||||
* Freigaben → Untis Mobile → QR-Code — and the error says so, because that is
|
||||
* the only place to find them.
|
||||
*/
|
||||
function untisConfig(): UntisConfig | undefined {
|
||||
const server = process.env.UNTIS_SERVER?.trim();
|
||||
const school = process.env.UNTIS_SCHOOL?.trim();
|
||||
const user = process.env.UNTIS_USER?.trim();
|
||||
const secret = process.env.UNTIS_SECRET?.trim();
|
||||
const missing = Object.entries({ UNTIS_SERVER: server, UNTIS_SCHOOL: school, UNTIS_USER: user, UNTIS_SECRET: secret })
|
||||
.filter(([, value]) => !value)
|
||||
.map(([name]) => name);
|
||||
if (missing.length === 4) return undefined;
|
||||
if (!server || !school || !user || !secret) {
|
||||
throw new Error(
|
||||
`WebUntis needs all four of UNTIS_SERVER, UNTIS_SCHOOL, UNTIS_USER and UNTIS_SECRET — missing: ` +
|
||||
`${missing.join(', ')}. All four are in WebUntis → Profil → Freigaben → Untis Mobile → QR-Code.`,
|
||||
);
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(server)) {
|
||||
throw new Error(
|
||||
`UNTIS_SERVER must be the bare host from the QR dialog's "Url" field, e.g. "ags-erfurt.webuntis.com" — ` +
|
||||
`no scheme and no path, got "${server}".`,
|
||||
);
|
||||
}
|
||||
// A credential: the error states the rule and never echoes the value.
|
||||
if (!/^[A-Za-z2-7]{8,}$/.test(secret)) {
|
||||
throw new Error(
|
||||
'UNTIS_SECRET must be the key from the Untis Mobile QR dialog: at least 8 characters of A-Z and 2-7 ' +
|
||||
'(base32), no spaces.',
|
||||
);
|
||||
}
|
||||
return { server, school, user, secret };
|
||||
}
|
||||
|
||||
/**
|
||||
* The app password, or undefined when the app is switched off.
|
||||
*
|
||||
* A length floor and nothing else: this one is typed by a person on a phone,
|
||||
* so demanding punctuation would buy little and cost the thing that actually
|
||||
* matters, which is that they pick something long. The error states the rule
|
||||
* and never echoes the value.
|
||||
*/
|
||||
function webPassword(): string | undefined {
|
||||
const value = process.env.WEB_PASSWORD;
|
||||
if (!value) return undefined;
|
||||
if (value.length < 12) {
|
||||
throw new Error(
|
||||
'WEB_PASSWORD must be at least 12 characters — it is the only thing between the internet and the ' +
|
||||
'notes app. A passphrase of three or four words is ideal.',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Like `int`, but 0 is meaningful (it disables the feature) rather than invalid. */
|
||||
function intAllowingZero(name: string, fallback: number): number {
|
||||
const raw = process.env[name]?.trim();
|
||||
@@ -65,10 +222,23 @@ function intAllowingZero(name: string, fallback: number): number {
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
const authToken = process.env.MCP_AUTH_TOKEN?.trim() || undefined;
|
||||
const connectorToken = secretToken('MCP_CONNECTOR_TOKEN');
|
||||
if (connectorToken && !authToken) {
|
||||
// Without the main token /api would be open while /mcp is not.
|
||||
throw new Error('MCP_CONNECTOR_TOKEN needs MCP_AUTH_TOKEN as well, or /api would be left unauthenticated.');
|
||||
}
|
||||
if (connectorToken && connectorToken === authToken) {
|
||||
throw new Error('MCP_CONNECTOR_TOKEN must differ from MCP_AUTH_TOKEN, or it cannot be limited to /mcp or revoked on its own.');
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: required('TSC_URL').replace(/\/+$/, ''),
|
||||
jwt: required('TSC_JWT_COOKIE'),
|
||||
authToken: process.env.MCP_AUTH_TOKEN?.trim() || undefined,
|
||||
authToken,
|
||||
connectorToken,
|
||||
mcpPathSecret: pathSecret('MCP_PATH_SECRET'),
|
||||
stateDir: process.env.STATE_DIR?.trim() ? resolve(process.env.STATE_DIR.trim()) : undefined,
|
||||
port: int('PORT', 8080),
|
||||
bindHost: process.env.BIND_HOST?.trim() || '0.0.0.0',
|
||||
maxDownloadBytes: int('MAX_DOWNLOAD_BYTES', 25 * 1024 * 1024),
|
||||
@@ -76,8 +246,24 @@ export function loadConfig(): Config {
|
||||
requestTimeoutMs: int('REQUEST_TIMEOUT_MS', 30_000),
|
||||
keepaliveIntervalMs: intAllowingZero('KEEPALIVE_INTERVAL_MS', 30 * 60_000),
|
||||
databaseUrl: process.env.DATABASE_URL?.trim() || undefined,
|
||||
mirrorDir: process.env.MIRROR_DIR?.trim() || '/var/lib/schulcloud-mcp/mirror',
|
||||
// Absolute: res.sendFile rejects a relative path, and resolveWithin only
|
||||
// returns an absolute path if the root it is given is one.
|
||||
mirrorDir: resolve(process.env.MIRROR_DIR?.trim() || '/var/lib/schulcloud-mcp/mirror'),
|
||||
mirrorMaxBytes: int('MIRROR_MAX_BYTES', 64 * 1024 * 1024),
|
||||
// Off by default: submissions are per task, so this roughly doubles the
|
||||
// cost of a full crawl. Worth turning on to make your own handed-in work
|
||||
// searchable, which no other route offers.
|
||||
indexPersonalFiles: bool('INDEX_PERSONAL_FILES', false),
|
||||
// On by default: many teachers keep their material only in Kurs-Dateien,
|
||||
// so an index without it misses whole courses. One page load per folder.
|
||||
indexFileManager: bool('INDEX_FILE_MANAGER', true),
|
||||
crawlIntervalMs: intAllowingZero('CRAWL_INTERVAL_MS', 6 * 60 * 60_000),
|
||||
// Absolute for the same reason as the mirror: resolveWithin only returns
|
||||
// an absolute path when the root it is given is one.
|
||||
webPassword: webPassword(),
|
||||
notesDir: process.env.NOTES_DIR?.trim() ? resolve(process.env.NOTES_DIR.trim()) : undefined,
|
||||
notesWritable: !bool('NOTES_READONLY', false),
|
||||
untisHistoryDays: intAllowingZero('UNTIS_HISTORY_DAYS', 180),
|
||||
untis: untisConfig(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Config } from './config.ts';
|
||||
import { SchulcloudClient } from './core/client.ts';
|
||||
import type { MeResponse } from './core/types.ts';
|
||||
import { FileManager } from './core/legacy-files.ts';
|
||||
import type { LegacyUser, MeResponse } from './core/types.ts';
|
||||
import { UntisClient } from './core/untis.ts';
|
||||
import type { Indexer } from './indexer/indexer.ts';
|
||||
import type { Store } from './store/store.ts';
|
||||
|
||||
@@ -14,16 +16,42 @@ import type { Store } from './store/store.ts';
|
||||
export class ServerContext {
|
||||
readonly config: Config;
|
||||
readonly client: SchulcloudClient;
|
||||
/** The "Dateien" file manager; shared across sessions when the process provides one. */
|
||||
readonly files: FileManager;
|
||||
/** Shared across sessions; undefined when running without an index. */
|
||||
readonly store: Store | undefined;
|
||||
readonly indexer: Indexer | undefined;
|
||||
/** WebUntis — the timetable — or undefined when the server has no key for it. */
|
||||
readonly untis: UntisClient | undefined;
|
||||
private identity: Promise<MeResponse> | undefined;
|
||||
/**
|
||||
* id -> display name, for the whole session.
|
||||
*
|
||||
* Submission `submitters`, file `creatorId` and course `teacherIds` are all
|
||||
* bare ids, and the only route that resolves one is `/api/v1/users/{id}` —
|
||||
* one request per person. Names do not change within a session and the same
|
||||
* handful of people recur across every course, so this is cached hard,
|
||||
* including the misses: a lookup a student is not allowed to make would
|
||||
* otherwise be retried for every row it appears in.
|
||||
*/
|
||||
private readonly userNames = new Map<string, Promise<string | undefined>>();
|
||||
|
||||
constructor(config: Config, shared?: { client?: SchulcloudClient; store?: Store; indexer?: Indexer }) {
|
||||
constructor(
|
||||
config: Config,
|
||||
shared?: {
|
||||
client?: SchulcloudClient;
|
||||
files?: FileManager;
|
||||
store?: Store;
|
||||
indexer?: Indexer;
|
||||
untis?: UntisClient;
|
||||
},
|
||||
) {
|
||||
this.config = config;
|
||||
this.client = shared?.client ?? new SchulcloudClient(config);
|
||||
this.files = shared?.files ?? new FileManager(this.client);
|
||||
this.store = shared?.store;
|
||||
this.indexer = shared?.indexer;
|
||||
this.untis = shared?.untis ?? (config.untis ? new UntisClient(config.untis, config.requestTimeoutMs) : undefined);
|
||||
}
|
||||
|
||||
/** Cached `/me`. Shared promise, so concurrent first calls make one request. */
|
||||
@@ -40,8 +68,56 @@ export class ServerContext {
|
||||
return (await this.me()).school.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name for a user id, or undefined when it cannot be resolved.
|
||||
*
|
||||
* Never throws: a 403 here is ordinary — a student may read their own
|
||||
* classmates but not every id that appears on a board — and a row that
|
||||
* falls back to the bare id is far better than a tool that fails.
|
||||
*/
|
||||
userName(userId: string): Promise<string | undefined> {
|
||||
let pending = this.userNames.get(userId);
|
||||
if (!pending) {
|
||||
pending = this.client
|
||||
.getLegacyUser(userId)
|
||||
.then((user: LegacyUser) => {
|
||||
const name = user.fullName ?? user.displayName ?? [user.firstName, user.lastName].filter(Boolean).join(' ');
|
||||
return name.trim() || undefined;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
this.userNames.set(userId, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Resolves several ids at once, falling back to the id itself. */
|
||||
async userNamesFor(userIds: string[]): Promise<string[]> {
|
||||
const unique = [...new Set(userIds)];
|
||||
const names = await Promise.all(unique.map(async (id) => (await this.userName(id)) ?? id));
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves several ids, reporting how many could not be read.
|
||||
*
|
||||
* `/api/v1/users/{id}` answers 403 for anyone but yourself unless the account
|
||||
* has permission over them: a teacher can read their students, a student
|
||||
* cannot read their teachers. Printing the raw id in that case is noise, so
|
||||
* callers that would show a name to a human use this and say "2 others"
|
||||
* instead of pasting two 24-character ids.
|
||||
*/
|
||||
async resolveNames(userIds: string[]): Promise<{ names: string[]; unresolved: number }> {
|
||||
const unique = [...new Set(userIds)];
|
||||
const resolved = await Promise.all(unique.map((id) => this.userName(id)));
|
||||
return {
|
||||
names: resolved.filter((name): name is string => Boolean(name)),
|
||||
unresolved: resolved.filter((name) => !name).length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Drops the cached identity so the next call re-reads it. */
|
||||
reset(): void {
|
||||
this.identity = undefined;
|
||||
this.userNames.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { Config } from '../config.ts';
|
||||
import type { SchulcloudClient } from './client.ts';
|
||||
import { fetchPadText } from './etherpad.ts';
|
||||
import { readH5pContent, type H5pContent } from './h5p.ts';
|
||||
import { SchulcloudApiError } from './client.ts';
|
||||
import type { BoardSkeleton, CardResponse, ContentElement, FileRecord } from './types.ts';
|
||||
|
||||
@@ -22,6 +25,20 @@ export interface AssembledElement {
|
||||
files: FileRecord[];
|
||||
/** Set when this element's files could not be resolved. */
|
||||
fileError?: string;
|
||||
/** What a class actually wrote in a collaborativeTextEditor (Etherpad) pad. */
|
||||
padText?: string;
|
||||
/** Longer body text: a link's description, a drawing's, a deleted element's. */
|
||||
description?: string;
|
||||
/** Image alt text — often the only description a picture carries. */
|
||||
alternativeText?: string;
|
||||
/** H5P content id: the handle onto interactive content (quizzes and the like). */
|
||||
h5pContentId?: string;
|
||||
/** The exercise behind that id, when it was resolved: every question at once. */
|
||||
h5p?: H5pContent;
|
||||
/** Which configured tool an externalTool element launches. */
|
||||
contextExternalToolId?: string;
|
||||
/** What a deleted element used to be. */
|
||||
deletedElementType?: string;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -52,7 +69,7 @@ export async function assembleBoard(
|
||||
client: SchulcloudClient,
|
||||
boardId: string,
|
||||
schoolId: string,
|
||||
options: { resolveFiles?: boolean } = {},
|
||||
options: { resolveFiles?: boolean; resolvePads?: Config; resolveH5p?: boolean } = {},
|
||||
): Promise<AssembledBoard> {
|
||||
const resolveFiles = options.resolveFiles ?? true;
|
||||
|
||||
@@ -71,6 +88,19 @@ export async function assembleBoard(
|
||||
await attachFiles(client, assembled, schoolId);
|
||||
}
|
||||
|
||||
// A pad's text is real course content, and nothing else surfaces it: the
|
||||
// board API returns collaborativeTextEditor elements with empty content.
|
||||
// Costs two requests per pad and only when a board has one.
|
||||
if (options.resolvePads) {
|
||||
await attachPadText(options.resolvePads, assembled);
|
||||
}
|
||||
|
||||
// A quiz is course material too, and the board hands over only a contentId.
|
||||
// One request per H5P element, and only when a board has one.
|
||||
if (options.resolveH5p) {
|
||||
await attachH5pContent(client, assembled);
|
||||
}
|
||||
|
||||
const fileCount = assembled
|
||||
.flatMap((column) => column.cards)
|
||||
.flatMap((card) => card.elements)
|
||||
@@ -109,18 +139,96 @@ function buildElement(element: ContentElement): AssembledElement {
|
||||
if (element.type === 'link') {
|
||||
if (typeof content.url === 'string') assembled.url = content.url;
|
||||
if (typeof content.title === 'string') assembled.text = content.title;
|
||||
// A link's description is where the teacher says why it is worth opening.
|
||||
if (typeof content.description === 'string') assembled.description = content.description;
|
||||
}
|
||||
if ((element.type === 'file' || element.type === 'fileFolder') && typeof content.caption === 'string') {
|
||||
const caption = content.caption.trim();
|
||||
if (caption) assembled.text = caption;
|
||||
}
|
||||
// For an image this is frequently the only text describing what it shows,
|
||||
// and it is the one field a screen-reader user is guaranteed to get.
|
||||
if (element.type === 'file' && typeof content.alternativeText === 'string') {
|
||||
assembled.alternativeText = content.alternativeText;
|
||||
}
|
||||
if (element.type === 'fileFolder' && typeof content.title === 'string') {
|
||||
const title = content.title.trim();
|
||||
if (title) assembled.text = title;
|
||||
}
|
||||
if (element.type === 'drawing' && typeof content.description === 'string') {
|
||||
assembled.description = content.description;
|
||||
}
|
||||
if (element.type === 'collaborativeTextEditor' || element.type === 'externalTool') {
|
||||
if (typeof content.title === 'string') assembled.text = content.title;
|
||||
}
|
||||
// videoConference carries a title too; dropping it left the element rendered
|
||||
// as a bare id with no hint of which meeting it is.
|
||||
if (element.type === 'videoConference' && typeof content.title === 'string') {
|
||||
assembled.text = content.title;
|
||||
}
|
||||
if (element.type === 'externalTool' && typeof content.contextExternalToolId === 'string') {
|
||||
assembled.contextExternalToolId = content.contextExternalToolId;
|
||||
}
|
||||
// The only handle onto H5P content — quizzes and other interactive material
|
||||
// reach the board this way, and without the id there is nothing to follow.
|
||||
if (element.type === 'h5p' && typeof content.contentId === 'string') {
|
||||
assembled.h5pContentId = content.contentId;
|
||||
}
|
||||
if (element.type === 'deleted') {
|
||||
if (typeof content.title === 'string') assembled.text = content.title;
|
||||
if (typeof content.description === 'string') assembled.description = content.description;
|
||||
if (typeof content.deletedElementType === 'string') assembled.deletedElementType = content.deletedElementType;
|
||||
}
|
||||
|
||||
return assembled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills in the text of each collaborative text editor element.
|
||||
*
|
||||
* Failures are left silent rather than recorded: unlike a missing attachment,
|
||||
* an unreadable pad is usually an empty one, and the element itself is still
|
||||
* reported.
|
||||
*/
|
||||
async function attachPadText(config: Config, columns: AssembledColumn[]): Promise<void> {
|
||||
const pads = columns
|
||||
.flatMap((column) => column.cards)
|
||||
.flatMap((card) => card.elements)
|
||||
.filter((element) => element.type === 'collaborativeTextEditor');
|
||||
|
||||
await Promise.all(
|
||||
pads.map(async (element) => {
|
||||
const text = await fetchPadText(config, element.id);
|
||||
if (!text) return;
|
||||
// The element's own title, when it has one, stays as the heading.
|
||||
element.padText = text;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills in the exercise behind each H5P element.
|
||||
*
|
||||
* Silent on failure, like a pad: the element and its content id are still
|
||||
* reported, and get_h5p then says why it could not be read.
|
||||
*/
|
||||
async function attachH5pContent(client: SchulcloudClient, columns: AssembledColumn[]): Promise<void> {
|
||||
const elements = columns
|
||||
.flatMap((column) => column.cards)
|
||||
.flatMap((card) => card.elements)
|
||||
.filter((element) => element.type === 'h5p' && element.h5pContentId);
|
||||
|
||||
await Promise.all(
|
||||
elements.map(async (element) => {
|
||||
try {
|
||||
element.h5p = await readH5pContent(client, element.h5pContentId!);
|
||||
} catch {
|
||||
// Left unset: the formatter falls back to naming the content id.
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves file-bearing elements to file records.
|
||||
*
|
||||
|
||||
@@ -3,16 +3,29 @@ import type {
|
||||
BoardContext,
|
||||
BoardSkeleton,
|
||||
CardResponse,
|
||||
ClassItem,
|
||||
CourseBoardResponse,
|
||||
CourseMetadata,
|
||||
DashboardResponse,
|
||||
FileParentType,
|
||||
FileRecord,
|
||||
GroupItem,
|
||||
LegacyCourse,
|
||||
LegacyUser,
|
||||
LessonResponse,
|
||||
MeResponse,
|
||||
NewsResponse,
|
||||
Paginated,
|
||||
ParentFileStats,
|
||||
PreviewWidth,
|
||||
SubmissionStatus,
|
||||
LessonLinkedTask,
|
||||
RoomApplicant,
|
||||
RoomBoardItem,
|
||||
RoomDetails,
|
||||
RoomInvitationLink,
|
||||
RoomItem,
|
||||
RoomMember,
|
||||
TaskContent,
|
||||
} from './types.ts';
|
||||
|
||||
@@ -22,6 +35,9 @@ import type {
|
||||
*/
|
||||
export const MAX_IDS_PER_QUERY = 20;
|
||||
|
||||
/** The only output format the preview endpoint accepts; anything else is a 400. */
|
||||
const PREVIEW_OUTPUT_FORMAT = 'image/webp';
|
||||
|
||||
/** Statuses worth retrying: transient by definition, and every call here is a GET. */
|
||||
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
|
||||
const MAX_RETRIES = 3;
|
||||
@@ -103,20 +119,43 @@ export class SchulcloudClient {
|
||||
return url;
|
||||
}
|
||||
|
||||
private async request(url: URL, accept: string): Promise<Response> {
|
||||
/**
|
||||
* One upstream GET, with retries for the transient failures.
|
||||
*
|
||||
* `auth` picks how the session travels. The v3 API takes it as a bearer
|
||||
* token; the legacy client's pages take it only as the `jwt` cookie; and a
|
||||
* pre-signed storage URL must get **nothing** — it lives on another host, and
|
||||
* the session token has no business leaving this instance. Anything but the
|
||||
* bearer form is fetched with redirects off, so a login bounce or a hop to a
|
||||
* third host is seen rather than silently followed with credentials attached.
|
||||
*/
|
||||
private async request(
|
||||
url: URL,
|
||||
accept: string,
|
||||
auth: 'bearer' | 'cookie' | 'none' = 'bearer',
|
||||
options: { idleTimeout?: boolean; token?: string } = {},
|
||||
): Promise<Response> {
|
||||
let lastError: unknown;
|
||||
const headers: Record<string, string> = { Accept: accept };
|
||||
// Read at the moment of use, never earlier: the token can be replaced
|
||||
// while the server runs (core/session-token.ts).
|
||||
const jwt = options.token ?? this.config.jwt;
|
||||
if (auth === 'bearer') headers.Authorization = `Bearer ${jwt}`;
|
||||
if (auth === 'cookie') headers.Cookie = `jwt=${jwt}`;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
if (attempt > 0) await delay(backoffMs(attempt));
|
||||
|
||||
let response: Response;
|
||||
const deadline = options.idleTimeout ? idleDeadline(this.config.requestTimeoutMs) : undefined;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${this.config.jwt}`, Accept: accept },
|
||||
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
|
||||
redirect: 'follow',
|
||||
headers,
|
||||
signal: deadline?.signal ?? AbortSignal.timeout(this.config.requestTimeoutMs),
|
||||
redirect: auth === 'bearer' ? 'follow' : 'manual',
|
||||
});
|
||||
} catch (error) {
|
||||
deadline?.stop();
|
||||
// Connection reset or timeout: worth one more try, since every call
|
||||
// here is an idempotent GET.
|
||||
lastError = error;
|
||||
@@ -124,10 +163,16 @@ export class SchulcloudClient {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (response.ok) return response;
|
||||
if (response.ok) return deadline ? deadline.watch(response) : response;
|
||||
deadline?.stop();
|
||||
|
||||
const body = await response.text().catch(() => '');
|
||||
const error = new SchulcloudApiError(response.status, url.pathname + url.search, body);
|
||||
// A pre-signed URL's query string is its credential, so it never goes
|
||||
// into an error message; nor does the storage host's error body.
|
||||
const error =
|
||||
auth === 'none'
|
||||
? new SchulcloudApiError(response.status, `${url.host} (pre-signed download)`, '')
|
||||
: new SchulcloudApiError(response.status, url.pathname + url.search, body);
|
||||
|
||||
// A crawl issues hundreds of requests and the instance answers some of
|
||||
// them with a 503 front-page when it decides we are going too fast.
|
||||
@@ -158,7 +203,12 @@ export class SchulcloudClient {
|
||||
*/
|
||||
async getBytes(path: string, fallbackName: string): Promise<DownloadedFile> {
|
||||
const url = this.url(path);
|
||||
const response = await this.request(url, '*/*');
|
||||
const response = await this.request(url, '*/*', 'bearer', { idleTimeout: true });
|
||||
return this.readCapped(response, fallbackName);
|
||||
}
|
||||
|
||||
/** Reads a response body up to `maxDownloadBytes`, flagging anything cut off. */
|
||||
private async readCapped(response: Response, fallbackName: string): Promise<DownloadedFile> {
|
||||
const limit = this.config.maxDownloadBytes;
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -206,6 +256,15 @@ export class SchulcloudClient {
|
||||
return this.getJson<MeResponse>('/api/v3/me');
|
||||
}
|
||||
|
||||
/**
|
||||
* `/me` as another token sees it, leaving the token in use untouched — how a
|
||||
* replacement is checked before it is swapped in.
|
||||
*/
|
||||
async meAs(token: string): Promise<MeResponse> {
|
||||
const response = await this.request(this.url('/api/v3/me'), 'application/json', 'bearer', { token });
|
||||
return (await response.json()) as MeResponse;
|
||||
}
|
||||
|
||||
// --- session ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -304,8 +363,81 @@ export class SchulcloudClient {
|
||||
return this.getJson<LessonResponse>(`/api/v3/lessons/${encodeURIComponent(lessonId)}`);
|
||||
}
|
||||
|
||||
getLessonTasks(lessonId: string): Promise<Paginated<TaskContent>> {
|
||||
return this.getJson<Paginated<TaskContent>>(`/api/v3/lessons/${encodeURIComponent(lessonId)}/tasks`);
|
||||
/**
|
||||
* A lesson's tasks.
|
||||
*
|
||||
* Returns a bare array, not the `{data, total}` envelope every other list
|
||||
* endpoint uses — checked against both the live instance and a local 33.40.
|
||||
* Typing it as `Paginated` made `.data` undefined, which silently dropped
|
||||
* every task attached to a topic: they vanished from get_lesson, get_task
|
||||
* reported them as non-existent, and their submissions — grades included —
|
||||
* could not be reached at all. The envelope branch is kept in case the
|
||||
* endpoint is ever normalised to match its siblings.
|
||||
*/
|
||||
async getLessonTasks(lessonId: string): Promise<LessonLinkedTask[]> {
|
||||
const body = await this.getJson<LessonLinkedTask[] | Paginated<LessonLinkedTask>>(
|
||||
`/api/v3/lessons/${encodeURIComponent(lessonId)}/tasks`,
|
||||
);
|
||||
return Array.isArray(body) ? body : (body.data ?? []);
|
||||
}
|
||||
|
||||
// --- rooms ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The rooms this account belongs to.
|
||||
*
|
||||
* Returns `{data}` with no `total` — not the usual paginated envelope. The
|
||||
* server derives the list from actual room memberships, so an empty result
|
||||
* means exactly that, and a room a teacher revoked access to simply stops
|
||||
* appearing.
|
||||
*/
|
||||
async listRooms(): Promise<RoomItem[]> {
|
||||
const body = await this.getJson<{ data?: RoomItem[] }>('/api/v3/rooms');
|
||||
return body.data ?? [];
|
||||
}
|
||||
|
||||
getRoom(roomId: string): Promise<RoomDetails> {
|
||||
return this.getJson<RoomDetails>(`/api/v3/rooms/${encodeURIComponent(roomId)}`);
|
||||
}
|
||||
|
||||
async listRoomBoards(roomId: string): Promise<RoomBoardItem[]> {
|
||||
const body = await this.getJson<Paginated<RoomBoardItem>>(
|
||||
`/api/v3/rooms/${encodeURIComponent(roomId)}/boards`,
|
||||
);
|
||||
return body.data ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* A room's members. Needs no special permission for a member to see who else
|
||||
* is in the room, but it can still be refused — callers treat that as "not
|
||||
* available" rather than as an error worth surfacing.
|
||||
*/
|
||||
async listRoomMembers(roomId: string): Promise<RoomMember[]> {
|
||||
const body = await this.getJson<{ data?: RoomMember[] }>(
|
||||
`/api/v3/rooms/${encodeURIComponent(roomId)}/members`,
|
||||
);
|
||||
return body.data ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* People waiting to be let into a room, and the room's invitation links.
|
||||
*
|
||||
* Both are room-admin surface: a viewer gets 403, which is ordinary rather
|
||||
* than exceptional. `allowedOperations` on the room says which of these the
|
||||
* account may ask for, so callers can skip the ones that would be refused.
|
||||
*/
|
||||
async listRoomApplicants(roomId: string): Promise<RoomApplicant[]> {
|
||||
const body = await this.getJson<{ data?: RoomApplicant[] }>(
|
||||
`/api/v3/rooms/${encodeURIComponent(roomId)}/applicants`,
|
||||
);
|
||||
return body.data ?? [];
|
||||
}
|
||||
|
||||
async listRoomInvitationLinks(roomId: string): Promise<RoomInvitationLink[]> {
|
||||
const body = await this.getJson<{ data?: RoomInvitationLink[] }>(
|
||||
`/api/v3/rooms/${encodeURIComponent(roomId)}/room-invitation-links`,
|
||||
);
|
||||
return body.data ?? [];
|
||||
}
|
||||
|
||||
// --- column boards ---------------------------------------------------
|
||||
@@ -318,6 +450,21 @@ export class SchulcloudClient {
|
||||
return this.getJson<BoardContext>(`/api/v3/boards/${encodeURIComponent(boardId)}/context`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The content behind an H5P element: a quiz with all of its questions.
|
||||
*
|
||||
* `params` is what the player loads before it renders anything, so one GET
|
||||
* returns the whole exercise — every question, every answer option and which
|
||||
* of them is correct — even though the player then shows one question at a
|
||||
* time. There is nothing to step through and no page to scrape.
|
||||
*
|
||||
* The shape belongs to whichever H5P library the content uses, so it stays
|
||||
* `unknown` here and `core/h5p.ts` interprets it.
|
||||
*/
|
||||
getH5pParams(contentId: string): Promise<unknown> {
|
||||
return this.getJson<unknown>(`/api/v3/h5p-editor/params/${encodeURIComponent(contentId)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card bodies for the given ids.
|
||||
*
|
||||
@@ -384,6 +531,239 @@ export class SchulcloudClient {
|
||||
limit: clampPageSize(params.limit),
|
||||
});
|
||||
}
|
||||
|
||||
/** File count and total bytes under one parent, without listing the records. */
|
||||
getParentFileStats(parentType: FileParentType, parentId: string): Promise<ParentFileStats> {
|
||||
return this.getJson<ParentFileStats>(
|
||||
`/api/v3/file/stats/${parentType}/${encodeURIComponent(parentId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A rasterised preview of one file.
|
||||
*
|
||||
* The reason this exists: many course PDFs are image-only scans, so text
|
||||
* extraction legitimately yields nothing and their contents are otherwise
|
||||
* unreadable. A preview is a picture of the page, which Claude can read
|
||||
* directly. Only meaningful when the record's `previewStatus` is
|
||||
* `preview_possible`; anything else 404s or returns the placeholder.
|
||||
*/
|
||||
async getFilePreview(
|
||||
record: Pick<FileRecord, 'id' | 'name'>,
|
||||
width?: PreviewWidth,
|
||||
): Promise<DownloadedFile> {
|
||||
// Two traps here, both of which answer with a 400 that names the value but
|
||||
// not the permitted set:
|
||||
// - `width` is an enum (50 | 150 | 500), not a free number;
|
||||
// - `outputFormat` accepts only `image/webp`. Omitting it is worse than
|
||||
// wrong: the preview is then rendered in the *source* format, so a PDF
|
||||
// comes back as a PDF and the whole point — a picture of the page — is
|
||||
// lost.
|
||||
const query = new URLSearchParams({ outputFormat: PREVIEW_OUTPUT_FORMAT });
|
||||
if (width) query.set('width', String(width));
|
||||
const path =
|
||||
`/api/v3/file/preview/${encodeURIComponent(record.id)}/${encodeURIComponent(record.name)}` +
|
||||
`?${query.toString()}`;
|
||||
const file = await this.getBytes(path, record.name);
|
||||
|
||||
// The response labels itself `webp` rather than `image/webp`, which no
|
||||
// image consumer would accept. We asked for the format, so we know it.
|
||||
return file.mimeType.startsWith('image/') ? file : { ...file, mimeType: PREVIEW_OUTPUT_FORMAT };
|
||||
}
|
||||
|
||||
// --- legacy /api/v1 ---------------------------------------------------
|
||||
//
|
||||
// Exactly three legacy routes survive in the deployment's ingress table
|
||||
// (dof_app_deploy .../all/x_ingress.yml): courses, users and classes. They
|
||||
// are production surface, not a leftover — the table even notes why each
|
||||
// one is still needed. Everything else under /api/v1 is unrouted and 404s,
|
||||
// so do not reach for it.
|
||||
|
||||
/** One course with the fields v3 drops: description, members, timetable. */
|
||||
getLegacyCourse(courseId: string): Promise<LegacyCourse> {
|
||||
return this.getJson<LegacyCourse>(`/api/v1/courses/${encodeURIComponent(courseId)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* One user's name.
|
||||
*
|
||||
* The only id-to-name mapping available: submission `submitters`, file
|
||||
* `creatorId` and course `teacherIds` are all bare ids, and no v3 route
|
||||
* resolves them for a non-admin.
|
||||
*/
|
||||
getLegacyUser(userId: string): Promise<LegacyUser> {
|
||||
return this.getJson<LegacyUser>(`/api/v1/users/${encodeURIComponent(userId)}`);
|
||||
}
|
||||
|
||||
// --- groups and classes ------------------------------------------------
|
||||
|
||||
/** Classes ("Klassen") this account belongs to, with teacher names. */
|
||||
async listClasses(): Promise<ClassItem[]> {
|
||||
const body = await this.getJson<Paginated<ClassItem>>('/api/v3/groups/class', { limit: MAX_PAGE_SIZE });
|
||||
return body.data ?? [];
|
||||
}
|
||||
|
||||
/** Groups this account belongs to — room membership groups, classes, courses. */
|
||||
async listGroups(): Promise<GroupItem[]> {
|
||||
const body = await this.getJson<Paginated<GroupItem>>('/api/v3/groups', { limit: MAX_PAGE_SIZE });
|
||||
return body.data ?? [];
|
||||
}
|
||||
|
||||
// --- the "Dateien" file manager ------------------------------------------
|
||||
//
|
||||
// Persönliche Dateien, Kurs-Dateien, Team-Dateien and Geteilte Dateien live in
|
||||
// the legacy file system, a different store from files-storage: listing a
|
||||
// course through /api/v3/file answers 0 files for a course holding dozens.
|
||||
// Its Feathers service is not in the public ingress, so the only way in is
|
||||
// the legacy client — HTML pages for listings, one JSON route for downloads.
|
||||
// See core/legacy-files.ts for the parsing and the path model.
|
||||
|
||||
/**
|
||||
* One file-manager page, as HTML.
|
||||
*
|
||||
* **Only the listing routes are reachable here, by construction.** Several of
|
||||
* the legacy client's GET routes write: `GET /files/share/` mints a share
|
||||
* token when the file has none, and `GET /files/file?share=…` grants the
|
||||
* caller a permission on someone else's file. A GET-only client is therefore
|
||||
* not read-only against this surface by itself; the allowlist is what makes
|
||||
* the invariant hold.
|
||||
*/
|
||||
async getFileManagerPage(path: string): Promise<string> {
|
||||
if (!FILE_MANAGER_PAGE.test(path)) {
|
||||
throw new Error(`refusing file-manager path outside the listing routes: ${path}`);
|
||||
}
|
||||
const response = await this.legacyRequest(path, 'text/html');
|
||||
return response.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* A pre-signed download URL for one legacy file.
|
||||
*
|
||||
* `name` only sets the download's filename; the server checks read access on
|
||||
* the id. The route is `/files/signedurl` rather than `/files/file`, which
|
||||
* answers the same thing as a redirect but also accepts `share`, the
|
||||
* parameter that writes.
|
||||
*/
|
||||
async getFileManagerSignedUrl(fileId: string, name: string): Promise<string> {
|
||||
if (!/^[0-9a-f]{24}$/i.test(fileId)) throw new Error(`not a file id: ${fileId}`);
|
||||
const query = new URLSearchParams({ file: fileId, name: name || fileId });
|
||||
const response = await this.legacyRequest(`/files/signedurl?${query.toString()}`, 'application/json');
|
||||
// The server's error path *returns* its Forbidden rather than throwing it,
|
||||
// so a refused file arrives as a 200 whose body has no url.
|
||||
const body = (await response.json().catch(() => ({}))) as { url?: unknown; message?: unknown };
|
||||
if (typeof body.url !== 'string' || !body.url) {
|
||||
throw new SchulcloudApiError(403, '/files/signedurl', typeof body.message === 'string' ? body.message : 'no download url');
|
||||
}
|
||||
return body.url;
|
||||
}
|
||||
|
||||
/** Downloads one legacy file: signed URL, then the bytes, capped like every download. */
|
||||
async downloadFileManagerFile(fileId: string, name: string): Promise<DownloadedFile> {
|
||||
const signed = await this.getFileManagerSignedUrl(fileId, name);
|
||||
const response = await this.openSignedUrl(signed);
|
||||
return this.readCapped(response, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a pre-signed storage URL — with no credentials at all.
|
||||
*
|
||||
* The URL names another host (live: an S3 endpoint at the storage provider),
|
||||
* so neither the bearer nor the cookie may go with it. It must also be
|
||||
* https whenever the instance is, which keeps a URL the server hands back from
|
||||
* pointing this process at a plaintext service on its own network.
|
||||
*/
|
||||
async openSignedUrl(signedUrl: string): Promise<Response> {
|
||||
const target = checkSignedUrl(signedUrl, this.config.baseUrl);
|
||||
return this.request(target, '*/*', 'none', { idleTimeout: true });
|
||||
}
|
||||
|
||||
private async legacyRequest(path: string, accept: string): Promise<Response> {
|
||||
try {
|
||||
return await this.request(this.url(path), accept, 'cookie');
|
||||
} catch (error) {
|
||||
// The legacy client answers a rejected cookie with a redirect to its
|
||||
// login page. Report it as what it is, so tools say "token expired"
|
||||
// rather than "HTTP 302".
|
||||
if (error instanceof SchulcloudApiError && error.status >= 300 && error.status < 400) {
|
||||
throw new SchulcloudApiError(401, path, 'redirected to login: the session is not accepted');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A timeout that measures silence rather than total time, for downloads.
|
||||
*
|
||||
* The request timeout is right for an API call and wrong for a file: it bounds
|
||||
* the whole transfer, so an 11 MB scan from a slow storage host was cut off at
|
||||
* 30 seconds while its bytes were still arriving — four files on the live
|
||||
* account, recorded as failures. Here the clock starts over with every chunk,
|
||||
* so only a transfer that stalls is abandoned.
|
||||
*/
|
||||
function idleDeadline(ms: number) {
|
||||
const controller = new AbortController();
|
||||
const expire = () => controller.abort(new Error(`no data received for ${Math.round(ms / 1000)}s`));
|
||||
let timer = setTimeout(expire, ms);
|
||||
// Never the reason a process stays alive: a caller that stops reading early
|
||||
// (the download cap) leaves the last timer behind.
|
||||
timer.unref();
|
||||
const rearm = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(expire, ms);
|
||||
timer.unref();
|
||||
};
|
||||
const stop = () => clearTimeout(timer);
|
||||
const watch = (response: Response): Response => {
|
||||
if (!response.body) {
|
||||
stop();
|
||||
return response;
|
||||
}
|
||||
rearm();
|
||||
const body = response.body.pipeThrough(
|
||||
new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, output) {
|
||||
rearm();
|
||||
output.enqueue(chunk);
|
||||
},
|
||||
flush() {
|
||||
stop();
|
||||
},
|
||||
}),
|
||||
);
|
||||
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
||||
};
|
||||
return { signal: controller.signal, stop, watch };
|
||||
}
|
||||
|
||||
/**
|
||||
* The file-manager listing routes, and nothing else.
|
||||
*
|
||||
* Folders are addressed by id alone — `/files/courses/{course}/{folder}` holds
|
||||
* one folder segment however deep the folder is — so every listing fits one of
|
||||
* these shapes. `/files/my/{a}/{b}` exists too, but lists `b` exactly as
|
||||
* `/files/my/{b}` does, so it is not needed.
|
||||
*/
|
||||
const FILE_MANAGER_PAGE =
|
||||
/^\/files\/(?:(?:my|courses|teams|shared)\/|my\/[0-9a-f]{24}|(?:courses|teams)\/[0-9a-f]{24}(?:\/[0-9a-f]{24})?)$/i;
|
||||
|
||||
/** Validates a pre-signed URL before anything is sent to it. Exported for testing. */
|
||||
export function checkSignedUrl(signedUrl: string, baseUrl: string): URL {
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(signedUrl);
|
||||
} catch {
|
||||
throw new Error('the download url the server returned is not a url');
|
||||
}
|
||||
const instanceIsHttps = new URL(baseUrl).protocol === 'https:';
|
||||
const allowed = instanceIsHttps ? ['https:'] : ['https:', 'http:'];
|
||||
if (!allowed.includes(target.protocol)) {
|
||||
throw new Error(`refusing a ${target.protocol} download url from an ${instanceIsHttps ? 'https' : 'http'} instance`);
|
||||
}
|
||||
if (target.username || target.password) {
|
||||
throw new Error('refusing a download url that carries credentials');
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { Config } from '../config.ts';
|
||||
import { h5pSearchText } from './h5p.ts';
|
||||
import { assembleBoard, type AssembledBoard } from './board.ts';
|
||||
import type { SchulcloudClient } from './client.ts';
|
||||
import { fetchHomeworkPage } from './homework-page.ts';
|
||||
import { FileManager, type DirectoryRef, type FmFile, type WalkEntry } from './legacy-files.ts';
|
||||
import { fetchLessonTaskLinks, withScrapedIds } from './lesson-page.ts';
|
||||
import { readNotes, type NoteDoc } from './notes.ts';
|
||||
import { htmlToText, normalizeObjectId } from './text.ts';
|
||||
import type { CourseMetadata, FileRecord, TaskContent } from './types.ts';
|
||||
import type { LessonLogEntry } from './untis-history.ts';
|
||||
import type { CourseMetadata, FileParentType, FileRecord, TaskContent } from './types.ts';
|
||||
|
||||
/**
|
||||
* Walks an account's entire content tree and returns it as one snapshot.
|
||||
@@ -16,7 +23,14 @@ import type { CourseMetadata, FileRecord, TaskContent } from './types.ts';
|
||||
* a separate pass keyed off the file records collected here.
|
||||
*/
|
||||
|
||||
/** Where an item sits, for building mirror paths and human-readable hits. */
|
||||
/**
|
||||
* Where an item sits, for building mirror paths and human-readable hits.
|
||||
*
|
||||
* `courseId`/`courseTitle` name the *container*, which since rooms were added
|
||||
* is a course or a room. The names are kept because they are also the manifest
|
||||
* wire format the CLI reads; renaming them would break older clients for no
|
||||
* gain here.
|
||||
*/
|
||||
export interface Breadcrumb {
|
||||
courseId: string;
|
||||
courseTitle: string;
|
||||
@@ -25,14 +39,48 @@ export interface Breadcrumb {
|
||||
/** Column → card, for board files. */
|
||||
columnTitle?: string;
|
||||
cardTitle?: string;
|
||||
/**
|
||||
* Folder names from the file manager, outermost first. Unlike boards, its
|
||||
* trees have no fixed depth, so they cannot be squeezed into the titles above.
|
||||
*/
|
||||
folders?: string[];
|
||||
}
|
||||
|
||||
export interface CrawledFile {
|
||||
record: FileRecord;
|
||||
/** Board element, lesson or task this file hangs off. */
|
||||
parentType: 'boardnodes' | 'lessons' | 'tasks';
|
||||
/** What this file hangs off. */
|
||||
parentType: FileParentType;
|
||||
parentId: string;
|
||||
at: Breadcrumb;
|
||||
/**
|
||||
* Which store holds the bytes. The file manager is not files-storage: its
|
||||
* ids mean nothing to /api/v3/file, so downloads must be routed by this.
|
||||
*/
|
||||
source?: 'files-storage' | 'file-manager';
|
||||
/** The file-manager path fs_read takes, for files from there. */
|
||||
fsPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One submission, with whatever the page could tell us about its grading.
|
||||
*
|
||||
* Indexed so that "what did the teacher say about X" and "what was graded this
|
||||
* week" are answerable at all: the submission endpoints carry no text and no
|
||||
* timestamps, so without this the whole grading surface is invisible to search
|
||||
* and to what_changed.
|
||||
*/
|
||||
export interface CrawledSubmission {
|
||||
id: string;
|
||||
taskId: string;
|
||||
taskName: string;
|
||||
courseId: string;
|
||||
courseTitle: string;
|
||||
isSubmitted: boolean;
|
||||
isGraded: boolean;
|
||||
grade?: number | null;
|
||||
gradeComment?: string;
|
||||
submittedText?: string;
|
||||
submitterIds: string[];
|
||||
}
|
||||
|
||||
export interface CrawledBoard {
|
||||
@@ -69,11 +117,44 @@ export interface CrawledCourse {
|
||||
tasks: CrawledTask[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A room and its boards.
|
||||
*
|
||||
* Rooms hold boards and nothing else — no lessons, no tasks — so this is
|
||||
* deliberately thinner than CrawledCourse rather than a course with empty
|
||||
* fields.
|
||||
*/
|
||||
export interface CrawledRoom {
|
||||
id: string;
|
||||
name: string;
|
||||
boards: CrawledBoard[];
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
crawledAt: Date;
|
||||
schoolId: string;
|
||||
courses: CrawledCourse[];
|
||||
/** Rooms ("Räume"), a separate space from courses. */
|
||||
rooms: CrawledRoom[];
|
||||
files: CrawledFile[];
|
||||
/** Populated only when `includePersonalFiles` is set; see that option. */
|
||||
submissions: CrawledSubmission[];
|
||||
/**
|
||||
* The user's own lesson notes, when `notesDir` was given.
|
||||
*
|
||||
* Not from Schulcloud and not fetched over the network — they are read off
|
||||
* disk. They travel in the snapshot because everything that consumes one
|
||||
* wants them: search should find what the user wrote alongside what the
|
||||
* teacher uploaded, and what_changed should notice a note appearing.
|
||||
*/
|
||||
notes: NoteDoc[];
|
||||
/**
|
||||
* The WebUntis class register for the recent past, when the caller attached
|
||||
* one. The crawl never fills this itself: it is a second upstream with its
|
||||
* own credential and its own request budget, so the indexer collects it and
|
||||
* hangs it here rather than making every live search pay for it.
|
||||
*/
|
||||
lessonLog: LessonLogEntry[];
|
||||
/**
|
||||
* Anything that could not be read, with the reason. Boards appear here too:
|
||||
* a board that fails must not vanish silently, or the index quietly loses
|
||||
@@ -84,12 +165,39 @@ export interface Snapshot {
|
||||
|
||||
export interface CrawlOptions {
|
||||
schoolId: string;
|
||||
/** The account's own user id — needed to reach its personal files. */
|
||||
userId?: string;
|
||||
/** Restrict to these courses. Omit for everything the account can see. */
|
||||
courseIds?: string[];
|
||||
/** Fetch lesson bodies too. Costs one request per lesson. */
|
||||
includeLessonContents?: boolean;
|
||||
/** Resolve board file elements to file records. */
|
||||
includeFiles?: boolean;
|
||||
/**
|
||||
* Also index the account's personal files and its submitted / returned work.
|
||||
*
|
||||
* Off by default because of what it costs: submissions are per task, so this
|
||||
* adds roughly three requests per task on top of a crawl that is already the
|
||||
* expensive part of this server. Worth it when you want "what did I write
|
||||
* about X" to be searchable, which is otherwise impossible.
|
||||
*/
|
||||
includePersonalFiles?: boolean;
|
||||
/**
|
||||
* Walk the file manager ("Dateien") too: Kurs-Dateien for every course, and on
|
||||
* a full crawl Persönliche Dateien, Team-Dateien and Geteilte Dateien. One
|
||||
* page load per folder — on the account this was built for, about 160.
|
||||
*/
|
||||
includeFileManager?: boolean;
|
||||
/**
|
||||
* Read the text of collaborative text editor (Etherpad) pads, which needs a
|
||||
* second credentialled hop outside the API. Omit to leave pads unread.
|
||||
*/
|
||||
config?: Config;
|
||||
/**
|
||||
* Read the user's own notes from this directory into the snapshot. Local
|
||||
* disk, so it is cheap enough for the live search path as well as the index.
|
||||
*/
|
||||
notesDir?: string;
|
||||
courseConcurrency?: number;
|
||||
boardConcurrency?: number;
|
||||
onProgress?: (done: number, total: number, label: string) => void;
|
||||
@@ -105,14 +213,30 @@ export async function crawl(client: SchulcloudClient, options: CrawlOptions): Pr
|
||||
|
||||
const crawled: CrawledCourse[] = [];
|
||||
const files: CrawledFile[] = [];
|
||||
const submissions: CrawledSubmission[] = [];
|
||||
const failures: { courseId: string; boardId?: string; reason: string }[] = [];
|
||||
let done = 0;
|
||||
|
||||
// Personal files ("Meine Dateien") hang off the user, not off any course, so
|
||||
// nothing in the course walk would ever reach them. One request, and only on
|
||||
// a full crawl — a per-course refresh has no business rewriting them.
|
||||
if (includeFiles && options.includePersonalFiles && !options.courseIds && options.userId) {
|
||||
for (const record of await listFiles(client, options.schoolId, 'users', options.userId)) {
|
||||
files.push({
|
||||
record,
|
||||
parentType: 'users',
|
||||
parentId: options.userId,
|
||||
at: { courseId: '', courseTitle: 'My files' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await forEachLimited(courses, options.courseConcurrency ?? 5, async (course) => {
|
||||
try {
|
||||
const result = await crawlCourse(client, course, options, includeFiles, includeLessons);
|
||||
crawled.push(result.course);
|
||||
files.push(...result.files);
|
||||
submissions.push(...result.submissions);
|
||||
failures.push(...result.failures);
|
||||
} catch (error) {
|
||||
failures.push({ courseId: course.id, reason: error instanceof Error ? error.message : String(error) });
|
||||
@@ -121,12 +245,164 @@ export async function crawl(client: SchulcloudClient, options: CrawlOptions): Pr
|
||||
}
|
||||
});
|
||||
|
||||
// Rooms are a separate space from courses and are only walked on a full
|
||||
// crawl: a per-course refresh names a course, and the store carries anything
|
||||
// outside that scope forward untouched.
|
||||
const rooms: CrawledRoom[] = options.courseIds ? [] : await crawlRooms(client, options, includeFiles, files, failures);
|
||||
|
||||
if (includeFiles && options.includeFileManager) {
|
||||
const titles = new Map(crawled.map((entry) => [entry.course.id, entry.title]));
|
||||
await crawlFileManager(client, options, titles, files, failures);
|
||||
}
|
||||
|
||||
// Traversal order is nondeterministic under concurrency; sort so that two
|
||||
// crawls of unchanged content produce identical snapshots.
|
||||
crawled.sort((a, b) => a.course.id.localeCompare(b.course.id));
|
||||
files.sort((a, b) => a.record.id.localeCompare(b.record.id));
|
||||
rooms.sort((a, b) => a.id.localeCompare(b.id));
|
||||
// One file can be reachable twice — a course file someone also shared with
|
||||
// you appears under /shared as well — and the index keys nodes by id. Keep
|
||||
// the first place it was found, which the traversal order makes the most
|
||||
// specific one.
|
||||
const seenFiles = new Set<string>();
|
||||
const uniqueFiles = files.filter((file) => (seenFiles.has(file.record.id) ? false : (seenFiles.add(file.record.id), true)));
|
||||
files.length = 0;
|
||||
files.push(...uniqueFiles);
|
||||
|
||||
return { crawledAt: new Date(), schoolId: options.schoolId, courses: crawled, files, failures };
|
||||
files.sort((a, b) => a.record.id.localeCompare(b.record.id));
|
||||
submissions.sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
// Local disk, and never fatal: a notes directory that does not exist yet is
|
||||
// an empty one, and a crawl must not fail over the half of the picture that
|
||||
// is not Schulcloud's.
|
||||
const notes = options.notesDir ? await readNotes(options.notesDir).catch(() => []) : [];
|
||||
|
||||
return {
|
||||
crawledAt: new Date(),
|
||||
schoolId: options.schoolId,
|
||||
courses: crawled,
|
||||
rooms,
|
||||
files,
|
||||
submissions,
|
||||
notes,
|
||||
lessonLog: [],
|
||||
failures,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the rooms this account belongs to.
|
||||
*
|
||||
* A room holds only boards. Unpublished ones are skipped rather than attempted:
|
||||
* the room board listing reports `isVisible`, so unlike a course board there is
|
||||
* no need to try one and record the resulting 403 as a failure.
|
||||
*
|
||||
* A room being unreadable is recorded, not swallowed — same rule as boards.
|
||||
*/
|
||||
async function crawlRooms(
|
||||
client: SchulcloudClient,
|
||||
options: CrawlOptions,
|
||||
includeFiles: boolean,
|
||||
files: CrawledFile[],
|
||||
failures: { courseId: string; boardId?: string; reason: string }[],
|
||||
): Promise<CrawledRoom[]> {
|
||||
let listed;
|
||||
try {
|
||||
listed = await client.listRooms();
|
||||
} catch (error) {
|
||||
failures.push({ courseId: '(rooms)', reason: error instanceof Error ? error.message : String(error) });
|
||||
return [];
|
||||
}
|
||||
|
||||
const rooms: CrawledRoom[] = [];
|
||||
await forEachLimited(listed, options.courseConcurrency ?? 5, async (room) => {
|
||||
const boards: CrawledBoard[] = [];
|
||||
try {
|
||||
const listing = await client.listRoomBoards(room.id);
|
||||
const readable = listing.filter((board) => board.isVisible !== false).map((board) => board.id);
|
||||
await crawlBoards(client, options, { id: room.id, title: room.name }, readable, includeFiles, boards, files, failures);
|
||||
} catch (error) {
|
||||
failures.push({ courseId: room.id, reason: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
boards.sort((a, b) => a.id.localeCompare(b.id));
|
||||
rooms.push({ id: room.id, name: room.name, boards });
|
||||
});
|
||||
return rooms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembles a set of boards into a container — a course or a room.
|
||||
*
|
||||
* Shared so that rooms are not a second, quietly divergent traversal: when pads
|
||||
* or file handling change here, both get it.
|
||||
*/
|
||||
async function crawlBoards(
|
||||
client: SchulcloudClient,
|
||||
options: CrawlOptions,
|
||||
container: { id: string; title: string },
|
||||
boardIds: string[],
|
||||
includeFiles: boolean,
|
||||
boards: CrawledBoard[],
|
||||
files: CrawledFile[],
|
||||
failures: { courseId: string; boardId?: string; reason: string }[],
|
||||
): Promise<void> {
|
||||
await forEachLimited(boardIds, options.boardConcurrency ?? 4, async (boardId) => {
|
||||
let assembled: AssembledBoard;
|
||||
try {
|
||||
assembled = await assembleBoard(client, boardId, options.schoolId, {
|
||||
resolveFiles: includeFiles,
|
||||
resolvePads: options.config,
|
||||
resolveH5p: true,
|
||||
});
|
||||
} catch (error) {
|
||||
// Record rather than swallow: a dropped board used to disappear from the
|
||||
// index while the crawl still reported success, which is how a 20-card
|
||||
// query limit went unnoticed.
|
||||
failures.push({
|
||||
courseId: container.id,
|
||||
boardId,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const column of assembled.columns) {
|
||||
for (const card of column.cards) {
|
||||
parts.push(card.title);
|
||||
for (const element of card.elements) {
|
||||
if (element.text) parts.push(htmlToText(element.text));
|
||||
// Pad contents are course material like any other; without this they
|
||||
// are unsearchable, and a pad is often where the actual group work is.
|
||||
if (element.padText) parts.push(element.padText);
|
||||
// A quiz's questions are material too: without this, searching for
|
||||
// something a teacher only asked in an H5P exercise finds nothing.
|
||||
if (element.h5p) parts.push(h5pSearchText(element.h5p));
|
||||
if (element.url) parts.push(element.url);
|
||||
for (const record of element.files) {
|
||||
files.push({
|
||||
record,
|
||||
parentType: 'boardnodes',
|
||||
parentId: element.id,
|
||||
at: {
|
||||
courseId: container.id,
|
||||
courseTitle: container.title,
|
||||
containerTitle: assembled.title,
|
||||
columnTitle: column.title,
|
||||
cardTitle: card.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
boards.push({
|
||||
id: assembled.id,
|
||||
title: assembled.title,
|
||||
courseId: container.id,
|
||||
board: assembled,
|
||||
text: parts.filter(Boolean).join('\n'),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function crawlCourse(
|
||||
@@ -135,10 +411,16 @@ async function crawlCourse(
|
||||
options: CrawlOptions,
|
||||
includeFiles: boolean,
|
||||
includeLessons: boolean,
|
||||
): Promise<{ course: CrawledCourse; files: CrawledFile[]; failures: { courseId: string; boardId: string; reason: string }[] }> {
|
||||
): Promise<{
|
||||
course: CrawledCourse;
|
||||
files: CrawledFile[];
|
||||
submissions: CrawledSubmission[];
|
||||
failures: { courseId: string; boardId: string; reason: string }[];
|
||||
}> {
|
||||
const page = await client.getCourseBoard(course.id);
|
||||
const title = page.title || course.title;
|
||||
const files: CrawledFile[] = [];
|
||||
const submissions: CrawledSubmission[] = [];
|
||||
const failures: { courseId: string; boardId: string; reason: string }[] = [];
|
||||
|
||||
const boards: CrawledBoard[] = [];
|
||||
@@ -161,6 +443,9 @@ async function crawlCourse(
|
||||
at: { courseId: course.id, courseTitle: title, containerTitle: element.content.name },
|
||||
});
|
||||
}
|
||||
if (options.includePersonalFiles) {
|
||||
await collectSubmissions(client, options, course.id, title, element.content, files, submissions);
|
||||
}
|
||||
}
|
||||
} else if (element.type === 'lesson') {
|
||||
const lesson: CrawledLesson = {
|
||||
@@ -187,6 +472,45 @@ async function crawlCourse(
|
||||
}
|
||||
}
|
||||
lessons.push(lesson);
|
||||
|
||||
// Tasks attached to a topic are not task elements on the course page,
|
||||
// so nothing above reaches them — and once past due they are absent
|
||||
// from both task lists too. On the account this was built for that is
|
||||
// 18 of 60 tasks: without this they are unsearchable and their grades
|
||||
// are invisible. The ids only exist on the topic page (lesson-page.ts).
|
||||
if (options.config && element.content.numberOfPublishedTasks) {
|
||||
const [linked, links] = await Promise.all([
|
||||
client.getLessonTasks(element.content.id).catch(() => []),
|
||||
fetchLessonTaskLinks(options.config, course.id, element.content.id),
|
||||
]);
|
||||
for (const linkedTask of withScrapedIds(linked, links)) {
|
||||
if (!linkedTask.id) continue;
|
||||
const body = htmlToText(linkedTask.description);
|
||||
const asTask: TaskContent = {
|
||||
id: linkedTask.id,
|
||||
name: linkedTask.name,
|
||||
courseId: course.id,
|
||||
courseName: title,
|
||||
lessonName: element.content.name,
|
||||
description: linkedTask.description,
|
||||
dueDate: linkedTask.dueDate ?? null,
|
||||
availableDate: linkedTask.availableDate,
|
||||
status: {
|
||||
submitted: 0,
|
||||
maxSubmissions: 0,
|
||||
graded: 0,
|
||||
isDraft: false,
|
||||
isSubstitutionTeacher: false,
|
||||
isFinished: false,
|
||||
},
|
||||
};
|
||||
tasks.push({ id: linkedTask.id, courseId: course.id, task: asTask, text: body });
|
||||
if (includeFiles && options.includePersonalFiles) {
|
||||
await collectSubmissions(client, options, course.id, title, asTask, files, submissions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (includeFiles) {
|
||||
for (const record of await listFiles(client, options.schoolId, 'lessons', element.content.id)) {
|
||||
files.push({
|
||||
@@ -200,63 +524,216 @@ async function crawlCourse(
|
||||
}
|
||||
}
|
||||
|
||||
await forEachLimited(boardIds, options.boardConcurrency ?? 4, async (boardId) => {
|
||||
let assembled: AssembledBoard;
|
||||
try {
|
||||
assembled = await assembleBoard(client, boardId, options.schoolId, { resolveFiles: includeFiles });
|
||||
} catch (error) {
|
||||
// Record rather than swallow: a dropped board used to disappear from the
|
||||
// index while the crawl still reported success, which is how a 20-card
|
||||
// query limit went unnoticed.
|
||||
failures.push({
|
||||
courseId: course.id,
|
||||
boardId,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
// The course's own file area ("Dateien" on the course page). One request per
|
||||
// course, and previously invisible: these files were reachable with
|
||||
// list_files but never indexed, so search and `schulcloud sync` missed them.
|
||||
if (includeFiles) {
|
||||
for (const record of await listFiles(client, options.schoolId, 'courses', course.id)) {
|
||||
files.push({
|
||||
record,
|
||||
parentType: 'courses',
|
||||
parentId: course.id,
|
||||
at: { courseId: course.id, courseTitle: title, containerTitle: 'Course files' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const column of assembled.columns) {
|
||||
for (const card of column.cards) {
|
||||
parts.push(card.title);
|
||||
for (const element of card.elements) {
|
||||
if (element.text) parts.push(htmlToText(element.text));
|
||||
if (element.url) parts.push(element.url);
|
||||
for (const record of element.files) {
|
||||
files.push({
|
||||
record,
|
||||
parentType: 'boardnodes',
|
||||
parentId: element.id,
|
||||
at: {
|
||||
courseId: course.id,
|
||||
courseTitle: title,
|
||||
containerTitle: assembled.title,
|
||||
columnTitle: column.title,
|
||||
cardTitle: card.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
boards.push({
|
||||
id: assembled.id,
|
||||
title: assembled.title,
|
||||
courseId: course.id,
|
||||
board: assembled,
|
||||
text: parts.filter(Boolean).join('\n'),
|
||||
});
|
||||
});
|
||||
await crawlBoards(client, options, { id: course.id, title }, boardIds, includeFiles, boards, files, failures);
|
||||
|
||||
boards.sort((a, b) => a.id.localeCompare(b.id));
|
||||
return { course: { course, title, boards, lessons, tasks }, files, failures };
|
||||
return { course: { course, title, boards, lessons, tasks }, files, submissions, failures };
|
||||
}
|
||||
|
||||
/**
|
||||
* What was handed in for one task, and what the teacher handed back.
|
||||
*
|
||||
* Both hang off the *submission* id, which is only obtainable from the status
|
||||
* endpoint — there is no submissions list. Note the asymmetry the file service
|
||||
* has here: listing with `parentType: 'gradings'` returns records whose own
|
||||
* `parentType` is `submissions`, so each record is filed under what it says it
|
||||
* is rather than under the path it was asked for. Without that split a
|
||||
* student's own upload would be indexed as teacher feedback.
|
||||
*/
|
||||
async function collectSubmissions(
|
||||
client: SchulcloudClient,
|
||||
options: CrawlOptions,
|
||||
courseId: string,
|
||||
courseTitle: string,
|
||||
task: TaskContent,
|
||||
files: CrawledFile[],
|
||||
submissions: CrawledSubmission[],
|
||||
): Promise<void> {
|
||||
const statuses = await client.listSubmissionStatuses(task.id).catch(() => []);
|
||||
if (statuses.length === 0) return;
|
||||
|
||||
// The grade comment and the submitted text exist only on the rendered page,
|
||||
// so one fetch per task covers every submission on it.
|
||||
const page = options.config
|
||||
? await fetchHomeworkPage(options.config, task.id).catch(() => undefined)
|
||||
: undefined;
|
||||
|
||||
for (const status of statuses) {
|
||||
const grading = page?.grading.find((entry) => entry.submissionId === status.id);
|
||||
submissions.push({
|
||||
id: status.id,
|
||||
taskId: task.id,
|
||||
taskName: task.name,
|
||||
courseId,
|
||||
courseTitle,
|
||||
isSubmitted: status.isSubmitted,
|
||||
isGraded: status.isGraded,
|
||||
grade: status.grade ?? grading?.gradePercent ?? null,
|
||||
gradeComment: grading?.gradeComment ?? page?.own?.gradeComment,
|
||||
submittedText: page?.own?.submittedText,
|
||||
submitterIds: status.submitters,
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of statuses) {
|
||||
for (const parentType of ['submissions', 'gradings'] as const) {
|
||||
for (const record of await listFiles(client, options.schoolId, parentType, status.id)) {
|
||||
files.push({
|
||||
record,
|
||||
parentType: record.parentType ?? parentType,
|
||||
parentId: status.id,
|
||||
at: {
|
||||
courseId,
|
||||
courseTitle,
|
||||
containerTitle: task.name,
|
||||
cardTitle: record.parentType === 'gradings' ? 'Returned by the teacher' : 'Handed in',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the file manager and records every file in it.
|
||||
*
|
||||
* Kurs-Dateien are filed under their course, so a per-course refresh replaces
|
||||
* exactly that course's files and the store's scope rules carry the rest
|
||||
* forward. The areas that belong to no course — personal, team, shared — are
|
||||
* walked only on a full crawl, for the same reason.
|
||||
*
|
||||
* A fresh FileManager rather than the process-wide one: its listing cache is
|
||||
* right for an interactive ls-then-read, and wrong for a crawl whose whole
|
||||
* point is to see the current state.
|
||||
*/
|
||||
async function crawlFileManager(
|
||||
client: SchulcloudClient,
|
||||
options: CrawlOptions,
|
||||
courseTitles: Map<string, string>,
|
||||
files: CrawledFile[],
|
||||
failures: { courseId: string; boardId?: string; reason: string }[],
|
||||
): Promise<void> {
|
||||
const manager = new FileManager(client);
|
||||
const roots: { ref: DirectoryRef; path: string; courseId: string; at: Omit<Breadcrumb, 'folders'> }[] = [];
|
||||
|
||||
if (options.courseIds) {
|
||||
for (const courseId of options.courseIds) {
|
||||
const title = courseTitles.get(courseId) ?? courseId;
|
||||
roots.push({
|
||||
ref: { area: 'courses', ownerId: courseId },
|
||||
path: `/courses/${title}`,
|
||||
courseId,
|
||||
at: { courseId, courseTitle: title, containerTitle: 'Kurs-Dateien' },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const owners = async (area: 'courses' | 'teams') => {
|
||||
try {
|
||||
return (await manager.list({ area })).directories;
|
||||
} catch (error) {
|
||||
failures.push({ courseId: '', reason: `file manager /${area}: ${error instanceof Error ? error.message : String(error)}` });
|
||||
return [];
|
||||
}
|
||||
};
|
||||
for (const course of await owners('courses')) {
|
||||
const title = courseTitles.get(course.id) ?? course.name;
|
||||
roots.push({
|
||||
ref: { area: 'courses', ownerId: course.id },
|
||||
path: `/courses/${course.name}`,
|
||||
courseId: course.id,
|
||||
at: { courseId: course.id, courseTitle: title, containerTitle: 'Kurs-Dateien' },
|
||||
});
|
||||
}
|
||||
for (const team of await owners('teams')) {
|
||||
roots.push({
|
||||
ref: { area: 'teams', ownerId: team.id },
|
||||
path: `/teams/${team.name}`,
|
||||
courseId: '',
|
||||
at: { courseId: '', courseTitle: 'Team-Dateien', containerTitle: team.name },
|
||||
});
|
||||
}
|
||||
roots.push({ ref: { area: 'my' }, path: '/my', courseId: '', at: { courseId: '', courseTitle: 'Persönliche Dateien' } });
|
||||
roots.push({ ref: { area: 'shared' }, path: '/shared', courseId: '', at: { courseId: '', courseTitle: 'Geteilte Dateien' } });
|
||||
}
|
||||
|
||||
// Sequential roots, modest concurrency within each: the instance answers a
|
||||
// burst with 503s, and this walk is the largest single part of a crawl.
|
||||
for (const root of roots) {
|
||||
const result = await manager.walk(
|
||||
{ path: root.path, ref: root.ref },
|
||||
{ maxDepth: 25, maxDirectories: 5000, concurrency: 2 },
|
||||
);
|
||||
for (const failure of result.failures) {
|
||||
failures.push({ courseId: root.courseId, reason: `file manager ${failure.path}: ${failure.reason}` });
|
||||
}
|
||||
if (result.truncated) {
|
||||
failures.push({ courseId: root.courseId, reason: `file manager ${root.path}: stopped at 5000 folders` });
|
||||
}
|
||||
|
||||
// Folder names come from the parent chain, never from splitting a path:
|
||||
// names contain "/" in real data.
|
||||
const folders = new Map<string, string[]>([[root.path, []]]);
|
||||
const ordered = [...result.entries].sort((a, b) => a.depth - b.depth);
|
||||
for (const entry of ordered) {
|
||||
if (entry.directory) {
|
||||
const parent = folders.get(entry.parentPath);
|
||||
if (parent) folders.set(entry.path, [...parent, entry.directory.name]);
|
||||
}
|
||||
}
|
||||
for (const entry of ordered) {
|
||||
if (!entry.file) continue;
|
||||
files.push(fileManagerRecord(entry, entry.file, root, folders.get(entry.parentPath) ?? []));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fileManagerRecord(
|
||||
entry: WalkEntry,
|
||||
file: FmFile,
|
||||
root: { ref: DirectoryRef; courseId: string; at: Omit<Breadcrumb, 'folders'> },
|
||||
folders: string[],
|
||||
): CrawledFile {
|
||||
const parentType: FileParentType = root.ref.area === 'courses' ? 'courses' : 'users';
|
||||
return {
|
||||
// Shaped like a files-storage record so the store, the mirror and the
|
||||
// manifest need no second code path; `source` is what keeps the two apart.
|
||||
record: {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
parentId: entry.parent.folderId ?? entry.parent.ownerId ?? '',
|
||||
parentType,
|
||||
url: '',
|
||||
size: file.size,
|
||||
mimeType: file.mimeType ?? 'application/octet-stream',
|
||||
securityCheckStatus: file.blocked ? 'blocked' : 'verified',
|
||||
previewStatus: '',
|
||||
},
|
||||
parentType,
|
||||
parentId: entry.parent.folderId ?? entry.parent.ownerId ?? '',
|
||||
at: { ...root.at, folders },
|
||||
source: 'file-manager',
|
||||
fsPath: entry.path,
|
||||
};
|
||||
}
|
||||
|
||||
async function listFiles(
|
||||
client: SchulcloudClient,
|
||||
schoolId: string,
|
||||
parentType: 'lessons' | 'tasks',
|
||||
parentType: FileParentType,
|
||||
parentId: string,
|
||||
): Promise<FileRecord[]> {
|
||||
const page = await client
|
||||
|
||||
86
src/core/dates.ts
Normal file
86
src/core/dates.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Calendar dates in the school's timezone.
|
||||
*
|
||||
* The container runs on UTC and the school does not: at 00:30 in Erfurt it is
|
||||
* still yesterday in UTC, so "today's timetable" derived from the process clock
|
||||
* would fetch the wrong day twice a night. Every date a person or the timetable
|
||||
* API sees is derived here instead, in Europe/Berlin.
|
||||
*
|
||||
* Dates are plain `YYYY-MM-DD` strings on purpose. A `Date` carries a time and
|
||||
* a zone, which is exactly what a school day does not have.
|
||||
*/
|
||||
|
||||
export const SCHOOL_TIME_ZONE = 'Europe/Berlin';
|
||||
|
||||
/** `YYYY-MM-DD`, in the school's timezone. */
|
||||
export function schoolToday(now: Date = new Date()): string {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: SCHOOL_TIME_ZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(now);
|
||||
const part = (type: Intl.DateTimeFormatPartTypes): string => parts.find((p) => p.type === type)?.value ?? '';
|
||||
return `${part('year')}-${part('month')}-${part('day')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a date that exists.
|
||||
*
|
||||
* The round trip is the point: `Date.parse` turns `2026-02-30` into March 2nd
|
||||
* rather than rejecting it, so a shape check alone would let a tool read a
|
||||
* different day than the caller asked for.
|
||||
*/
|
||||
export function isCalendarDate(value: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
const at = Date.parse(`${value}T12:00:00Z`);
|
||||
return !Number.isNaN(at) && new Date(at).toISOString().slice(0, 10) === value;
|
||||
}
|
||||
|
||||
function noonUtc(date: string): number {
|
||||
const at = Date.parse(`${date}T12:00:00Z`);
|
||||
if (Number.isNaN(at)) throw new Error(`Not a YYYY-MM-DD date: ${date}`);
|
||||
return at;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shifts a calendar date by whole days.
|
||||
*
|
||||
* Anchored at noon UTC so that no daylight-saving change can push the result
|
||||
* onto the neighbouring day — the arithmetic stays on calendar dates.
|
||||
*/
|
||||
export function addDays(date: string, days: number): string {
|
||||
return new Date(noonUtc(date) + days * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Whole days from `from` to `to`; negative when `to` is earlier. */
|
||||
export function daysBetween(from: string, to: string): number {
|
||||
return Math.round((noonUtc(to) - noonUtc(from)) / 86_400_000);
|
||||
}
|
||||
|
||||
/** `2026-09-21` → `Montag`. */
|
||||
export function germanWeekday(date: string): string {
|
||||
return new Intl.DateTimeFormat('de-DE', { weekday: 'long', timeZone: 'UTC' }).format(new Date(noonUtc(date)));
|
||||
}
|
||||
|
||||
/** `2026-09-21` → `21.09.2026`. */
|
||||
export function germanDay(date: string): string {
|
||||
const [year, month, day] = date.split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
/** A moment → `Dienstag, 15.09.2026` in the school's timezone. */
|
||||
export function germanDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: SCHOOL_TIME_ZONE,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/** `2026-09-21` → `20260921`, the compact form the WebUntis API takes. */
|
||||
export function compactDate(date: string): number {
|
||||
return Number(date.replace(/-/g, ''));
|
||||
}
|
||||
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, ' ');
|
||||
}
|
||||
154
src/core/etherpad.ts
Normal file
154
src/core/etherpad.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { Config } from '../config.ts';
|
||||
|
||||
/**
|
||||
* Reads the text of a collaborative text editor (Etherpad) element.
|
||||
*
|
||||
* The board API is no help on its own: a `collaborativeTextEditor` element
|
||||
* comes back with `content: {}` — no pad id, no url, nothing. What a class
|
||||
* actually wrote in it is invisible to every other tool here.
|
||||
*
|
||||
* Two calls recover it, and neither needs Etherpad's own API key (which is a
|
||||
* server-side secret this process has no business holding):
|
||||
*
|
||||
* 1. `GET /api/v3/collaborative-text-editor/content-element/{id}` answers with
|
||||
* the pad url *and*, in a `Set-Cookie`, an Etherpad `sessionID` — the same
|
||||
* exchange the web client performs before it embeds the pad.
|
||||
* 2. Etherpad's own `/p/{padId}/export/txt` returns the pad as plain text to
|
||||
* whoever holds that session.
|
||||
*
|
||||
* The session cookie is only ever sent back to the instance's own host: step 1
|
||||
* returns a url built from the server's `ETHERPAD__PAD_URI`, and a value
|
||||
* pointing anywhere else is refused rather than followed.
|
||||
*
|
||||
* Everything degrades to `undefined`. A pad that cannot be read costs its text,
|
||||
* never the board.
|
||||
*/
|
||||
export async function fetchPadText(config: Config, elementId: string): Promise<string | undefined> {
|
||||
try {
|
||||
const handle = await fetchPadHandle(config, elementId);
|
||||
if (!handle) return undefined;
|
||||
|
||||
const response = await fetch(`${handle.origin}/etherpad/p/${handle.padId}/export/txt`, {
|
||||
headers: { Cookie: handle.sessionCookie, Accept: 'text/plain' },
|
||||
signal: AbortSignal.timeout(config.requestTimeoutMs),
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
|
||||
const text = (await response.text()).trim();
|
||||
// A pad nobody has typed in still exports the placeholder the instance
|
||||
// seeds new pads with; reporting that as content would be a lie.
|
||||
return text.length > 0 && text !== DEFAULT_PAD_TEXT ? text : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The instance's `DEFAULT_PAD_TEXT`; an untouched pad exports exactly this. */
|
||||
const DEFAULT_PAD_TEXT = 'Schreib etwas!';
|
||||
|
||||
interface PadHandle {
|
||||
origin: string;
|
||||
padId: string;
|
||||
sessionCookie: string;
|
||||
}
|
||||
|
||||
async function fetchPadHandle(config: Config, elementId: string): Promise<PadHandle | undefined> {
|
||||
const response = await fetch(
|
||||
`${config.baseUrl}/api/v3/collaborative-text-editor/content-element/${encodeURIComponent(elementId)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${config.jwt}`, Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(config.requestTimeoutMs),
|
||||
},
|
||||
);
|
||||
if (!response.ok) return undefined;
|
||||
|
||||
const body = (await response.json()) as { url?: unknown };
|
||||
if (typeof body.url !== 'string') return undefined;
|
||||
|
||||
const padId = padIdFromUrl(body.url, config.baseUrl);
|
||||
if (!padId) return undefined;
|
||||
|
||||
// `getSetCookie` keeps the header split correctly; a plain `get` would join
|
||||
// several cookies on the commas that appear inside the session list itself.
|
||||
const sessionCookie = response.headers
|
||||
.getSetCookie()
|
||||
.map((cookie) => /^(sessionID=[^;]*)/.exec(cookie)?.[1])
|
||||
.find((value): value is string => Boolean(value));
|
||||
if (!sessionCookie) return undefined;
|
||||
|
||||
return { origin: new URL(config.baseUrl).origin, padId, sessionCookie };
|
||||
}
|
||||
|
||||
/**
|
||||
* The pad id out of the url the server hands back.
|
||||
*
|
||||
* Refuses a url on another host: that url is server-configured, and following
|
||||
* it blindly would send an Etherpad session cookie wherever it pointed.
|
||||
* Group pad ids contain `$`, so the segment is kept exactly as encoded.
|
||||
*/
|
||||
export function padIdFromUrl(url: string, baseUrl: string): string | undefined {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (parsed.host !== new URL(baseUrl).host) return undefined;
|
||||
|
||||
const segment = /\/etherpad\/p\/([^/?#]+)/.exec(parsed.pathname)?.[1];
|
||||
return segment && segment.length > 0 ? segment : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of a pad linked from a *topic* ("Thema"), as opposed to a board.
|
||||
*
|
||||
* Topics reach Etherpad differently from column boards: there is no
|
||||
* `collaborative-text-editor` element to ask, only a stored pad url on the
|
||||
* lesson component. The session cookie comes from the topic page instead —
|
||||
* the legacy client requests an Etherpad session on every topic page whose
|
||||
* lesson has contents, whether or not a pad is present, so simply rendering
|
||||
* the page yields one. Everything here is a GET.
|
||||
*
|
||||
* The stored url is data and may point anywhere; `padIdFromUrl` refuses any
|
||||
* host but this instance's, so a pad recorded against another deployment is
|
||||
* reported as a link rather than fetched — which is the correct outcome, not
|
||||
* a failure.
|
||||
*/
|
||||
export async function fetchLessonPadText(
|
||||
config: Config,
|
||||
courseId: string,
|
||||
lessonId: string,
|
||||
padUrl: string,
|
||||
): Promise<string | undefined> {
|
||||
const padId = padIdFromUrl(padUrl, config.baseUrl);
|
||||
if (!padId) return undefined;
|
||||
|
||||
try {
|
||||
const page = await fetch(
|
||||
`${config.baseUrl}/courses/${encodeURIComponent(courseId)}/topics/${encodeURIComponent(lessonId)}`,
|
||||
{
|
||||
headers: { Cookie: `jwt=${config.jwt}`, Accept: 'text/html' },
|
||||
signal: AbortSignal.timeout(config.requestTimeoutMs),
|
||||
redirect: 'follow',
|
||||
},
|
||||
);
|
||||
if (!page.ok) return undefined;
|
||||
|
||||
const sessionCookie = page.headers
|
||||
.getSetCookie()
|
||||
.map((cookie) => /^(sessionID=[^;]*)/.exec(cookie)?.[1])
|
||||
.find((value): value is string => Boolean(value));
|
||||
if (!sessionCookie) return undefined;
|
||||
|
||||
const response = await fetch(`${new URL(config.baseUrl).origin}/etherpad/p/${padId}/export/txt`, {
|
||||
headers: { Cookie: sessionCookie, Accept: 'text/plain' },
|
||||
signal: AbortSignal.timeout(config.requestTimeoutMs),
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
|
||||
const text = (await response.text()).trim();
|
||||
return text.length > 0 && text !== DEFAULT_PAD_TEXT ? text : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,10 @@ function hasTextLayer(bytes: Buffer): boolean {
|
||||
|
||||
async function extractPdf(bytes: Buffer): Promise<string> {
|
||||
const { extractText, getDocumentProxy } = await import('unpdf');
|
||||
const document = await getDocumentProxy(new Uint8Array(bytes));
|
||||
// verbosity 0 = errors only. pdf.js otherwise prints "Warning: TT: undefined
|
||||
// function" for every font hint it skips — harmless, but a crawl of the file
|
||||
// manager extracts hundreds of PDFs, and that buries the log in noise.
|
||||
const document = await getDocumentProxy(new Uint8Array(bytes), { verbosity: 0 });
|
||||
const { text } = await extractText(document, { mergePages: true });
|
||||
return Array.isArray(text) ? text.join('\n\n') : text;
|
||||
}
|
||||
|
||||
305
src/core/h5p.ts
Normal file
305
src/core/h5p.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Reads H5P content: the quizzes and interactive exercises on a board.
|
||||
*
|
||||
* Schulcloud has no quiz of its own — an exercise is an H5P element, and the
|
||||
* board API hands over nothing but a `contentId`. The player then shows one
|
||||
* question at a time, which makes a quiz look like something that has to be
|
||||
* stepped through or scraped. It is not: `GET /api/v3/h5p-editor/params/{id}`
|
||||
* returns the entire exercise as the JSON the player is fed, so one request
|
||||
* holds every question, every option and which of them is correct.
|
||||
*
|
||||
* What varies is the *shape* of that JSON, because it belongs to whichever H5P
|
||||
* library the teacher used. The common question types are modelled below; for
|
||||
* anything else the text is harvested generically and labelled as such, so an
|
||||
* exercise this parser does not know still arrives readable instead of empty.
|
||||
*/
|
||||
|
||||
import type { SchulcloudClient } from './client.ts';
|
||||
import { htmlToText } from './text.ts';
|
||||
|
||||
export interface H5pAnswer {
|
||||
text: string;
|
||||
/** Undefined when the library does not say — never guess a solution. */
|
||||
correct?: boolean;
|
||||
tip?: string;
|
||||
}
|
||||
|
||||
export interface H5pQuestion {
|
||||
/** The H5P library, e.g. `H5P.MultiChoice 1.16`. */
|
||||
library: string;
|
||||
/** A short human name for the kind of task, for the reader. */
|
||||
kind: string;
|
||||
text: string;
|
||||
answers: H5pAnswer[];
|
||||
/** True when more than one option is meant to be ticked. */
|
||||
multiple?: boolean;
|
||||
/** The task's own text where it is not the question: a cloze, a description. */
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface H5pContent {
|
||||
contentId: string;
|
||||
title: string;
|
||||
/** The main library of the content itself, e.g. `H5P.QuestionSet`. */
|
||||
library: string;
|
||||
intro?: string;
|
||||
/** Percentage needed to pass, when the content sets one. */
|
||||
passPercentage?: number;
|
||||
questions: H5pQuestion[];
|
||||
/**
|
||||
* Text from parts this parser does not model. Never silently dropped: a
|
||||
* teacher's exercise turning up as "0 questions" is the bug this avoids.
|
||||
*/
|
||||
unmodelled: string[];
|
||||
}
|
||||
|
||||
/** Fetches and parses one H5P content. Throws if it cannot be read or parsed. */
|
||||
export async function readH5pContent(client: SchulcloudClient, contentId: string): Promise<H5pContent> {
|
||||
return parseH5pParams(contentId, await client.getH5pParams(contentId));
|
||||
}
|
||||
|
||||
/** Everything in the content as one string, for the search index. */
|
||||
export function h5pSearchText(content: H5pContent): string {
|
||||
const parts = [content.title, content.intro];
|
||||
for (const question of content.questions) {
|
||||
parts.push(question.text, question.body, ...question.answers.map((answer) => answer.text));
|
||||
}
|
||||
parts.push(...content.unmodelled);
|
||||
return parts.filter((part): part is string => Boolean(part?.trim())).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Interprets a `params` payload.
|
||||
*
|
||||
* Throws on a payload that is not H5P at all rather than returning an empty
|
||||
* exercise — the difference between "this quiz has no questions" and "the
|
||||
* format changed" matters, and only one of them is worth reporting as content.
|
||||
*/
|
||||
export function parseH5pParams(contentId: string, payload: unknown): H5pContent {
|
||||
const root = asRecord(payload);
|
||||
const metadata = asRecord(root?.h5p);
|
||||
// `params.params` is the content itself; `params.metadata` beside it repeats
|
||||
// the H5P metadata. A single-question content has the same shape.
|
||||
const outer = asRecord(root?.params);
|
||||
const params = asRecord(outer?.params) ?? outer;
|
||||
const library = string(metadata?.mainLibrary) ?? string(root?.library) ?? 'unknown';
|
||||
if (!params) {
|
||||
throw new Error(`H5P content ${contentId} carried no params — the payload shape has changed`);
|
||||
}
|
||||
|
||||
const title = string(metadata?.title)?.trim() || 'H5P content';
|
||||
const content: H5pContent = { contentId, title, library, questions: [], unmodelled: [] };
|
||||
|
||||
const introPage = asRecord(params.introPage);
|
||||
const intro = htmlToText(string(introPage?.introduction) ?? string(params.intro) ?? '').trim();
|
||||
if (intro) content.intro = intro;
|
||||
if (typeof params.passPercentage === 'number') content.passPercentage = params.passPercentage;
|
||||
|
||||
// A QuestionSet holds a list of sub-contents, each with its own library; any
|
||||
// other library *is* the single question.
|
||||
const questions = Array.isArray(params.questions) && params.questions.some((entry) => asRecord(entry)?.library)
|
||||
? params.questions
|
||||
: undefined;
|
||||
if (questions) {
|
||||
for (const entry of questions) {
|
||||
const record = asRecord(entry);
|
||||
const sub = asRecord(record?.params);
|
||||
const subLibrary = string(record?.library) ?? 'unknown';
|
||||
if (sub) content.questions.push(...parseQuestion(subLibrary, sub, content.unmodelled));
|
||||
}
|
||||
} else {
|
||||
content.questions.push(...parseQuestion(library, params, content.unmodelled));
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* One sub-content as questions.
|
||||
*
|
||||
* Returns several for the libraries that bundle them (SingleChoiceSet,
|
||||
* Summary, Blanks) and none for one it cannot read, in which case its text goes
|
||||
* to `unmodelled`.
|
||||
*/
|
||||
function parseQuestion(library: string, params: Record<string, unknown>, unmodelled: string[]): H5pQuestion[] {
|
||||
const name = library.split(/\s+/)[0] ?? library;
|
||||
const question = (text: string, answers: H5pAnswer[], extra: Partial<H5pQuestion> = {}): H5pQuestion => ({
|
||||
library,
|
||||
kind: KIND[name] ?? name.replace(/^H5P\./, ''),
|
||||
text,
|
||||
answers,
|
||||
...extra,
|
||||
});
|
||||
|
||||
switch (name) {
|
||||
case 'H5P.MultiChoice': {
|
||||
const answers = (asArray(params.answers) ?? []).map((entry): H5pAnswer => {
|
||||
const answer = asRecord(entry);
|
||||
const tip = htmlToText(string(asRecord(answer?.tipsAndFeedback)?.tip) ?? '').trim();
|
||||
return {
|
||||
text: htmlToText(string(answer?.text) ?? '').trim(),
|
||||
correct: answer?.correct === true,
|
||||
...(tip ? { tip } : {}),
|
||||
};
|
||||
});
|
||||
// `singleAnswer` is what the player uses to choose radio buttons over
|
||||
// checkboxes, and it is the only honest way to say "tick one".
|
||||
const single = asRecord(params.behaviour)?.singleAnswer === true;
|
||||
return [
|
||||
question(htmlToText(string(params.question) ?? '').trim(), answers, {
|
||||
multiple: !single && answers.filter((answer) => answer.correct).length !== 1,
|
||||
}),
|
||||
];
|
||||
}
|
||||
case 'H5P.TrueFalse': {
|
||||
const l10n = asRecord(params.l10n);
|
||||
const yes = string(l10n?.trueText) ?? 'Wahr';
|
||||
const no = string(l10n?.falseText) ?? 'Falsch';
|
||||
// `correct` is the string "true" or "false", not a boolean.
|
||||
const correct = string(params.correct);
|
||||
return [
|
||||
question(htmlToText(string(params.question) ?? '').trim(), [
|
||||
{ text: yes, correct: correct === 'true' },
|
||||
{ text: no, correct: correct === 'false' },
|
||||
]),
|
||||
];
|
||||
}
|
||||
case 'H5P.Blanks': {
|
||||
const description = htmlToText(string(params.text) ?? '').trim();
|
||||
return (asArray(params.questions) ?? []).flatMap((entry) => {
|
||||
const raw = string(entry);
|
||||
if (!raw) return [];
|
||||
const { text, answers } = parseCloze(raw);
|
||||
return [question(text, answers, { body: description || undefined })];
|
||||
});
|
||||
}
|
||||
case 'H5P.DragText':
|
||||
case 'H5P.MarkTheWords': {
|
||||
const raw = string(params.textField) ?? '';
|
||||
const { text, answers } = parseCloze(raw);
|
||||
const description = htmlToText(string(params.taskDescription) ?? '').trim();
|
||||
return [question(description || 'Aufgabe', answers, { body: text })];
|
||||
}
|
||||
case 'H5P.SingleChoiceSet': {
|
||||
return (asArray(params.choices) ?? []).flatMap((entry) => {
|
||||
const choice = asRecord(entry);
|
||||
const texts = (asArray(choice?.answers) ?? []).map((answer) => htmlToText(string(answer) ?? '').trim());
|
||||
// The first option is the correct one; the player shuffles them.
|
||||
const answers = texts.map((text, index): H5pAnswer => ({ text, correct: index === 0 }));
|
||||
return [question(htmlToText(string(choice?.question) ?? '').trim(), answers)];
|
||||
});
|
||||
}
|
||||
case 'H5P.Summary': {
|
||||
return (asArray(params.summaries) ?? []).flatMap((entry) => {
|
||||
const group = asRecord(entry);
|
||||
const texts = (asArray(group?.summary) ?? []).map((item) => htmlToText(string(item) ?? '').trim());
|
||||
const answers = texts.map((text, index): H5pAnswer => ({ text, correct: index === 0 }));
|
||||
return [question(htmlToText(string(params.intro) ?? '').trim() || 'Welche Aussage stimmt?', answers)];
|
||||
});
|
||||
}
|
||||
case 'H5P.Column': {
|
||||
// A column stacks sub-contents; each carries its own library.
|
||||
return (asArray(params.content) ?? []).flatMap((entry) => {
|
||||
const inner = asRecord(asRecord(entry)?.content);
|
||||
const innerParams = asRecord(inner?.params);
|
||||
const innerLibrary = string(inner?.library) ?? 'unknown';
|
||||
return innerParams ? parseQuestion(innerLibrary, innerParams, unmodelled) : [];
|
||||
});
|
||||
}
|
||||
default: {
|
||||
const harvested = harvest(params);
|
||||
if (harvested.length > 0) unmodelled.push(`${name}: ${harvested.join(' | ')}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Short names for the libraries worth naming; anything else keeps its own. */
|
||||
const KIND: Record<string, string> = {
|
||||
'H5P.MultiChoice': 'multiple choice',
|
||||
'H5P.TrueFalse': 'true/false',
|
||||
'H5P.Blanks': 'fill in the blanks',
|
||||
'H5P.DragText': 'drag the words',
|
||||
'H5P.MarkTheWords': 'mark the words',
|
||||
'H5P.SingleChoiceSet': 'single choice',
|
||||
'H5P.Summary': 'pick the correct statement',
|
||||
};
|
||||
|
||||
/**
|
||||
* A cloze text: H5P marks the solutions inline as `*answer:tip*`, with
|
||||
* alternatives separated by slashes.
|
||||
*/
|
||||
export function parseCloze(raw: string): { text: string; answers: H5pAnswer[] } {
|
||||
const answers: H5pAnswer[] = [];
|
||||
const text = htmlToText(
|
||||
raw.replace(/\*([^*]+)\*/g, (_, body: string) => {
|
||||
const [solutions, tip] = body.split(':');
|
||||
const alternatives = (solutions ?? '').split('/').map((part) => part.trim()).filter(Boolean);
|
||||
answers.push({
|
||||
text: alternatives.join(' / '),
|
||||
correct: true,
|
||||
...(tip?.trim() ? { tip: tip.trim() } : {}),
|
||||
});
|
||||
return `____ (${answers.length})`;
|
||||
}),
|
||||
).trim();
|
||||
return { text, answers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every bit of task text in an unmodelled library.
|
||||
*
|
||||
* Deliberately blunt — it cannot know which field is the question — but it
|
||||
* skips the subtrees that hold button labels and display settings, which
|
||||
* otherwise drown the content in "Überprüfen" and "Wiederholen".
|
||||
*/
|
||||
const SKIP_KEYS = new Set([
|
||||
'UI',
|
||||
'l10n',
|
||||
'behaviour',
|
||||
'overallFeedback',
|
||||
'confirmCheck',
|
||||
'confirmRetry',
|
||||
'media',
|
||||
'localization',
|
||||
'a11y',
|
||||
'accessibility',
|
||||
'scoreBarLabel',
|
||||
'texts',
|
||||
'endGame',
|
||||
'override',
|
||||
]);
|
||||
|
||||
function harvest(value: unknown, depth = 0, out: string[] = []): string[] {
|
||||
if (depth > 6 || out.length > 40) return out;
|
||||
if (typeof value === 'string') {
|
||||
const text = htmlToText(value).trim();
|
||||
// Two characters of prose, not a colour code or a library version.
|
||||
if (text.length > 2 && /\p{L}{2}/u.test(text) && !out.includes(text)) out.push(text);
|
||||
return out;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) harvest(item, depth + 1, out);
|
||||
return out;
|
||||
}
|
||||
const record = asRecord(value);
|
||||
if (record) {
|
||||
for (const [key, item] of Object.entries(record)) {
|
||||
if (SKIP_KEYS.has(key)) continue;
|
||||
harvest(item, depth + 1, out);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] | undefined {
|
||||
return Array.isArray(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function string(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
@@ -38,12 +38,51 @@ export interface SubmissionDetail {
|
||||
submittedFiles: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One submission as the *teacher's* grading form holds it.
|
||||
*
|
||||
* The teacher view of `/homework/{id}` is a different page from the student's:
|
||||
* its tabs are `extended` and `submissions` rather than `submission` and
|
||||
* `feedback`, and the grade lives in the editable form rather than in rendered
|
||||
* prose. The student parser therefore finds nothing on it, which is why a
|
||||
* teacher account reported every graded submission as "neither a percentage nor
|
||||
* feedback was found" while the data was plainly there.
|
||||
*/
|
||||
export interface SubmissionGrading {
|
||||
submissionId: string;
|
||||
/** Ids from the form's `teamMembers` field — who handed this in. */
|
||||
submitterIds: string[];
|
||||
gradeComment?: string;
|
||||
gradePercent?: number;
|
||||
}
|
||||
|
||||
/** Everything one homework page yields, for whichever role is looking at it. */
|
||||
export interface HomeworkPage {
|
||||
/** The account's own submission, when the page is the student view. */
|
||||
own?: SubmissionDetail;
|
||||
/** Every submission on the grading form, when the page is the teacher view. */
|
||||
grading: SubmissionGrading[];
|
||||
}
|
||||
|
||||
export async function fetchHomeworkPage(config: Config, taskId: string): Promise<HomeworkPage | undefined> {
|
||||
const html = await fetchHomeworkHtml(config, taskId);
|
||||
if (html === undefined) return undefined;
|
||||
const own = parseHomeworkPage(html);
|
||||
const grading = parseTeacherGrading(html);
|
||||
if (!own && grading.length === 0) return undefined;
|
||||
return { own, grading };
|
||||
}
|
||||
|
||||
export async function fetchSubmissionDetail(
|
||||
config: Config,
|
||||
taskId: string,
|
||||
): Promise<SubmissionDetail | undefined> {
|
||||
const html = await fetchHomeworkHtml(config, taskId);
|
||||
return html === undefined ? undefined : parseHomeworkPage(html);
|
||||
}
|
||||
|
||||
async function fetchHomeworkHtml(config: Config, taskId: string): Promise<string | undefined> {
|
||||
const url = `${config.baseUrl}/homework/${encodeURIComponent(taskId)}`;
|
||||
let html: string;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { Cookie: `jwt=${config.jwt}`, Accept: 'text/html' },
|
||||
@@ -55,12 +94,51 @@ export async function fetchSubmissionDetail(
|
||||
if (!response.ok || !new URL(response.url).hostname.endsWith(new URL(config.baseUrl).hostname)) {
|
||||
return undefined;
|
||||
}
|
||||
html = await response.text();
|
||||
return await response.text();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return parseHomeworkPage(html);
|
||||
/**
|
||||
* Exported for testing: reads the teacher's grading form.
|
||||
*
|
||||
* Anchored on the form's own `name=` attributes rather than on layout, because
|
||||
* those are what the POST handler reads and so cannot drift without the feature
|
||||
* itself changing. Each submission contributes one `submissionId` hidden input,
|
||||
* a `teamMembers` input naming who handed it in, a `grade` number input, and a
|
||||
* `gradeComment` textarea whose body is HTML-escaped twice over.
|
||||
*/
|
||||
export function parseTeacherGrading(html: string): SubmissionGrading[] {
|
||||
const found: SubmissionGrading[] = [];
|
||||
const blocks = html.split(/<input name="submissionId"/);
|
||||
for (const block of blocks.slice(1)) {
|
||||
const submissionId = /value="([0-9a-f]{24})"/.exec(block)?.[1];
|
||||
if (!submissionId) continue;
|
||||
|
||||
// Only trust fields belonging to this submission: the next block starts
|
||||
// at the following submissionId input, so cut there first.
|
||||
const members = /<input name="teamMembers"[^>]*value="([^"]*)"/.exec(block)?.[1] ?? '';
|
||||
const submitterIds = members
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => /^[0-9a-f]{24}$/.test(id));
|
||||
|
||||
const entry: SubmissionGrading = { submissionId, submitterIds };
|
||||
|
||||
// `value=""` means ungraded; the placeholder is a hint, not a grade.
|
||||
const gradeValue = /name="grade"[^>]*?value="(\d{1,3})"/.exec(block)?.[1];
|
||||
if (gradeValue !== undefined) entry.gradePercent = Number(gradeValue);
|
||||
|
||||
const commentMarkup = new RegExp(
|
||||
`<textarea[^>]*data-parent-id="${submissionId}"[^>]*>([\\s\\S]*?)</textarea>`,
|
||||
).exec(html)?.[1];
|
||||
const comment = clean(commentMarkup ? decodeEntities(commentMarkup) : undefined);
|
||||
if (comment) entry.gradeComment = comment;
|
||||
|
||||
found.push(entry);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Exported for testing: the parsing is pure and deserves fixtures, not a network. */
|
||||
@@ -73,9 +151,17 @@ export function parseHomeworkPage(html: string): SubmissionDetail | undefined {
|
||||
|
||||
// The student's own text: a textarea while the submission is still editable,
|
||||
// a plain div once it is not.
|
||||
//
|
||||
// The read-only div is a *sibling after* `</section id="submission">`, not a
|
||||
// child of it — that section then holds only the file list. Scoping this
|
||||
// search to the section therefore found nothing for every submission past
|
||||
// its due date, silently dropping the submitted text while still reporting
|
||||
// the grade. `class="comment"` (with the quote right after the word) is
|
||||
// specific enough to search the whole page: the teacher's feedback is
|
||||
// `class="comment ckcontent"` and so cannot match.
|
||||
const typed =
|
||||
/data-testid="submission-text"[^>]*>([\s\S]*?)<\/textarea>/.exec(html)?.[1] ??
|
||||
(submission ? /<div class="comment"[^>]*>([\s\S]*?)<\/div>/.exec(submission)?.[1] : undefined);
|
||||
/<div class="comment"[^>]*>([\s\S]*?)<\/div>/.exec(html)?.[1];
|
||||
const typedText = clean(typed);
|
||||
if (typedText) detail.submittedText = typedText;
|
||||
|
||||
|
||||
@@ -24,9 +24,27 @@ import { SchulcloudApiError } from './client.ts';
|
||||
* shared key out from under us. See docs/AUTH.md — the fix is to close the tab,
|
||||
* not to ping harder.
|
||||
*/
|
||||
export interface KeepaliveState {
|
||||
running: boolean;
|
||||
/** Seconds of session the instance reported at the last successful extension. */
|
||||
budgetSeconds: number | undefined;
|
||||
lastExtendedAt: string | undefined;
|
||||
/** Set once the instance refused the token; cleared by a restart. */
|
||||
rejectedAt: string | undefined;
|
||||
}
|
||||
|
||||
export class SessionKeepalive {
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private stopped = false;
|
||||
/**
|
||||
* Bumped by every start and stop. A ping still in flight when the token is
|
||||
* replaced was sent with the old token, and its 401 must not stop the
|
||||
* keepalive that is already running with the new one.
|
||||
*/
|
||||
private generation = 0;
|
||||
private budgetSeconds: number | undefined;
|
||||
private lastExtendedAt: Date | undefined;
|
||||
private rejectedAt: Date | undefined;
|
||||
private readonly client: SchulcloudClient;
|
||||
private readonly intervalMs: number;
|
||||
/** Retry delay after a failed ping — shorter, to use up the remaining budget. */
|
||||
@@ -47,27 +65,52 @@ export class SessionKeepalive {
|
||||
|
||||
/** Pings once now (validating the token at startup), then on the interval. */
|
||||
start(): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
this.stopped = false;
|
||||
void this.tick();
|
||||
this.rejectedAt = undefined;
|
||||
void this.tick(++this.generation);
|
||||
}
|
||||
|
||||
/** Starts over with the token now in use — after a replacement, including one that follows a 401. */
|
||||
restart(): void {
|
||||
this.start();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
this.generation++;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
|
||||
private schedule(delayMs: number): void {
|
||||
if (this.stopped) return;
|
||||
this.timer = setTimeout(() => void this.tick(), delayMs);
|
||||
state(): KeepaliveState {
|
||||
return {
|
||||
running: !this.stopped,
|
||||
budgetSeconds: this.budgetSeconds,
|
||||
lastExtendedAt: this.lastExtendedAt?.toISOString(),
|
||||
rejectedAt: this.rejectedAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private current(generation: number): boolean {
|
||||
return !this.stopped && generation === this.generation;
|
||||
}
|
||||
|
||||
private schedule(delayMs: number, generation: number): void {
|
||||
if (!this.current(generation)) return;
|
||||
this.timer = setTimeout(() => void this.tick(generation), delayMs);
|
||||
// Never hold the process open just for a keepalive.
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
private async tick(generation: number): Promise<void> {
|
||||
if (!this.current(generation)) return;
|
||||
try {
|
||||
const { expiresInSeconds } = await this.client.extendSession();
|
||||
if (!this.current(generation)) return;
|
||||
this.budgetSeconds = expiresInSeconds;
|
||||
this.lastExtendedAt = new Date();
|
||||
// A budget well below the instance's JWT_TIMEOUT_SECONDS means the
|
||||
// extension is not taking effect — worth seeing in the log, because it
|
||||
// is the early warning that the session is about to be lost.
|
||||
@@ -75,8 +118,9 @@ export class SessionKeepalive {
|
||||
`[schulcloud-mcp] keepalive: session extended, ${expiresInSeconds}s ` +
|
||||
`(${Math.round(expiresInSeconds / 60)} min) of budget left`,
|
||||
);
|
||||
this.schedule(this.intervalMs);
|
||||
this.schedule(this.intervalMs, generation);
|
||||
} catch (error) {
|
||||
if (!this.current(generation)) return;
|
||||
if (error instanceof SchulcloudApiError && error.isAuthFailure) {
|
||||
// Past saving: the whitelist entry is gone, or the JWT hit its 30-day
|
||||
// ceiling. Pinging harder cannot revive it — a human must paste a new
|
||||
@@ -86,16 +130,18 @@ export class SessionKeepalive {
|
||||
'If this is ~2h after login, the likely cause is a Schulportal tab left open ' +
|
||||
'on the same token, whose auto-logout revoked it — close the tab. Otherwise ' +
|
||||
'the server was down past the 2h window, or the JWT hit its 30-day limit. ' +
|
||||
'Put a fresh jwt cookie in TSC_JWT_COOKIE and restart. Keepalive stopped.',
|
||||
'Hand the server a fresh jwt cookie with `schulcloud token set` or on its /token page; ' +
|
||||
'the keepalive resumes by itself. Keepalive stopped.',
|
||||
);
|
||||
this.stop();
|
||||
this.rejectedAt = new Date();
|
||||
return;
|
||||
}
|
||||
this.log(
|
||||
`[schulcloud-mcp] keepalive: ping failed (${error instanceof Error ? error.message : String(error)}); ` +
|
||||
`retrying in ${Math.round(this.retryMs / 1000)}s`,
|
||||
);
|
||||
this.schedule(this.retryMs);
|
||||
this.schedule(this.retryMs, generation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
500
src/core/legacy-files.ts
Normal file
500
src/core/legacy-files.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
import type { DownloadedFile, SchulcloudClient } from './client.ts';
|
||||
import { decodeEntities } from './text.ts';
|
||||
|
||||
/**
|
||||
* The "Dateien" file manager — Persönliche Dateien, Kurs-Dateien, Team-Dateien
|
||||
* and Geteilte Dateien — as one read-only filesystem.
|
||||
*
|
||||
* This is the legacy file system, a different store from files-storage
|
||||
* (`/api/v3/file`), which holds board, topic and task attachments. The two do
|
||||
* not overlap: asking files-storage for a course's files answers 0 for a course
|
||||
* whose file manager holds dozens, and on the account this was built for 21 of
|
||||
* 26 courses keep material here — some teachers use nothing else.
|
||||
*
|
||||
* Its Feathers service is not in the public ingress, so it is reached through
|
||||
* the legacy client: server-rendered listing pages, parsed here, and one JSON
|
||||
* route for pre-signed downloads (see the client). Two server quirks shape the
|
||||
* design:
|
||||
*
|
||||
* - `GET /files/permittedDirectories/` looks like the obvious JSON source for
|
||||
* the directory tree, but its query matches course folders on
|
||||
* `refOwnerModel: 'courses'` while the records say `'course'`. It lists every
|
||||
* course with **no** folders in any of them. Listings are the only complete
|
||||
* view, which is also exactly what the file manager itself shows.
|
||||
* - `GET /files/search/` runs an unindexed regex over every file record and
|
||||
* times out (504) on the live instance, so finding is done by walking.
|
||||
*/
|
||||
|
||||
export type FileArea = 'my' | 'courses' | 'teams' | 'shared';
|
||||
|
||||
interface AreaInfo {
|
||||
area: FileArea;
|
||||
label: string;
|
||||
/** Accepted spellings of the first path segment, compared lower-cased. */
|
||||
aliases: string[];
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export const FILE_AREAS: AreaInfo[] = [
|
||||
{
|
||||
area: 'my',
|
||||
label: 'Persönliche Dateien',
|
||||
aliases: ['my', 'persönliche dateien', 'persoenliche dateien', 'personal', 'meine dateien'],
|
||||
summary: 'your own files',
|
||||
},
|
||||
{
|
||||
area: 'courses',
|
||||
label: 'Kurs-Dateien',
|
||||
aliases: ['courses', 'kurs-dateien', 'meine kurs-dateien', 'kursdateien', 'kurse'],
|
||||
summary: 'one folder per course, holding what its teachers uploaded',
|
||||
},
|
||||
{
|
||||
area: 'teams',
|
||||
label: 'Team-Dateien',
|
||||
aliases: ['teams', 'team-dateien', 'meine team-dateien', 'teamdateien'],
|
||||
summary: 'one folder per team',
|
||||
},
|
||||
{
|
||||
area: 'shared',
|
||||
label: 'Geteilte Dateien',
|
||||
aliases: ['shared', 'geteilte dateien', 'mit mir geteilt'],
|
||||
summary: 'files other people shared with you, read-only and flat',
|
||||
},
|
||||
];
|
||||
|
||||
export function areaInfo(area: FileArea): AreaInfo {
|
||||
return FILE_AREAS.find((entry) => entry.area === area) as AreaInfo;
|
||||
}
|
||||
|
||||
export interface FmDirectory {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FmFile {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Bytes, as the listing reports it. */
|
||||
size: number;
|
||||
/** Absent for a blocked file: the page withholds its viewer attributes. */
|
||||
mimeType?: string;
|
||||
/** Rejected by the instance's virus scanner; it cannot be downloaded. */
|
||||
blocked: boolean;
|
||||
}
|
||||
|
||||
export interface FmListing {
|
||||
directories: FmDirectory[];
|
||||
files: FmFile[];
|
||||
}
|
||||
|
||||
/** A listing page's markup changed shape; never report that as an empty folder. */
|
||||
export class FileManagerMarkupError extends Error {}
|
||||
|
||||
/**
|
||||
* Parses one file-manager page. Exported for testing.
|
||||
*
|
||||
* Anchored on what the templates (`files/files.hbs`, `files/files-grid.hbs`)
|
||||
* emit for the page's own scripts — `data-folder-id`, `data-file-id`,
|
||||
* `data-file-name`, `data-file-size` — rather than on layout classes.
|
||||
*
|
||||
* Throws rather than returning an empty listing when the page is not a file
|
||||
* manager page at all: "0 files" is precisely the wrong answer this module
|
||||
* exists to fix, and a markup change must not quietly reproduce it.
|
||||
*/
|
||||
export function parseFileListing(html: string): FmListing {
|
||||
if (!/class="route-files"/.test(html)) {
|
||||
throw new FileManagerMarkupError('the page is not a file-manager listing (markup changed, or not logged in)');
|
||||
}
|
||||
|
||||
const directories: FmDirectory[] = [];
|
||||
const folderPattern = /<button\b([^>]*\bopenfolder\b[^>]*)>([\s\S]*?)<\/button>/g;
|
||||
for (const match of html.matchAll(folderPattern)) {
|
||||
const id = /data-folder-id="([0-9a-f]{24})"/i.exec(match[1] ?? '')?.[1];
|
||||
if (!id) continue;
|
||||
// The name is emitted unescaped (`{{{stripOnlyScript name}}}`) inside the
|
||||
// title element, after an icon; strip the tags, then decode what is left.
|
||||
const title = /<strong\b[^>]*card-title-directory[^>]*>([\s\S]*?)<\/strong>/.exec(match[2] ?? '')?.[1] ?? '';
|
||||
const name = decodeEntities(title.replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim();
|
||||
directories.push({ id, name: name || id });
|
||||
}
|
||||
|
||||
const files: FmFile[] = [];
|
||||
const cardPattern = /<div\b[^>]*\bclass="card file\b([^"]*)"([^>]*)>/g;
|
||||
const cards = [...html.matchAll(cardPattern)];
|
||||
cards.forEach((match, index) => {
|
||||
const attributes = match[2] ?? '';
|
||||
const id = /data-file-id="([0-9a-f]{24})"/i.exec(attributes)?.[1];
|
||||
if (!id) return;
|
||||
const name = decodeEntities(/data-file-name="([^"]*)"/.exec(attributes)?.[1] ?? '');
|
||||
const size = Number(/data-file-size="(\d*)"/.exec(attributes)?.[1] ?? '');
|
||||
// The viewer attributes sit further inside this card; look no further
|
||||
// than the next card, so one file can never borrow another's type.
|
||||
const start = (match.index ?? 0) + match[0].length;
|
||||
const end = cards[index + 1]?.index ?? html.length;
|
||||
const mimeType = /data-file-viewer-type="([^"]*)"/.exec(html.slice(start, end))?.[1];
|
||||
files.push({
|
||||
id,
|
||||
name: name || id,
|
||||
size: Number.isFinite(size) ? size : 0,
|
||||
mimeType: mimeType ? decodeEntities(mimeType) : undefined,
|
||||
blocked: /\bbtn-file-danger\b/.test(match[1] ?? ''),
|
||||
});
|
||||
});
|
||||
|
||||
return { directories, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a directory is. `area` absent is the root; `ownerId` is the course or
|
||||
* team (absent for `my` and `shared`); `folderId` absent is the owner's top.
|
||||
*/
|
||||
export interface DirectoryRef {
|
||||
area?: FileArea;
|
||||
ownerId?: string;
|
||||
folderId?: string;
|
||||
}
|
||||
|
||||
export type FsNode =
|
||||
| { kind: 'directory'; path: string; ref: DirectoryRef; name: string }
|
||||
| { kind: 'file'; path: string; parent: DirectoryRef; file: FmFile };
|
||||
|
||||
export type FsErrorCode = 'not_found' | 'ambiguous' | 'not_a_directory' | 'not_a_file' | 'not_navigable';
|
||||
|
||||
export class FsError extends Error {
|
||||
readonly code: FsErrorCode;
|
||||
constructor(code: FsErrorCode, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** The legacy page that lists a directory. */
|
||||
export function pageFor(ref: DirectoryRef): string | undefined {
|
||||
if (!ref.area) return undefined;
|
||||
if (ref.area === 'shared') return '/files/shared/';
|
||||
if (ref.area === 'my') return ref.folderId ? `/files/my/${ref.folderId}` : '/files/my/';
|
||||
if (!ref.ownerId) return `/files/${ref.area}/`;
|
||||
return ref.folderId ? `/files/${ref.area}/${ref.ownerId}/${ref.folderId}` : `/files/${ref.area}/${ref.ownerId}`;
|
||||
}
|
||||
|
||||
/** The reference for a subdirectory found in `parent`'s listing. */
|
||||
export function childRef(parent: DirectoryRef, directory: FmDirectory): DirectoryRef {
|
||||
if (!parent.area) return { area: directory.id as FileArea };
|
||||
if (parent.area === 'my') return { area: 'my', folderId: directory.id };
|
||||
if (parent.area === 'shared') {
|
||||
// The file manager has no route that opens a shared folder — its owner is
|
||||
// someone else, and every listing route is scoped to an owner. The UI's
|
||||
// own link to one is a 404.
|
||||
throw new FsError(
|
||||
'not_navigable',
|
||||
`"${directory.name}" is a folder someone shared with you. The file manager cannot open shared folders ` +
|
||||
'(not even in the browser); ask for the files themselves to be shared, or find them in the owning course.',
|
||||
);
|
||||
}
|
||||
if (!parent.ownerId) return { area: parent.area, ownerId: directory.id };
|
||||
return { area: parent.area, ownerId: parent.ownerId, folderId: directory.id };
|
||||
}
|
||||
|
||||
/** Splits a path into raw segments. Empty segments (`//`, trailing `/`) drop out. */
|
||||
export function splitPath(path: string): string[] {
|
||||
return path
|
||||
.split('/')
|
||||
.map((segment) => segment.trim())
|
||||
.filter((segment) => segment.length > 0);
|
||||
}
|
||||
|
||||
/** Joins names into a display path. Names keep any `/` they contain; see `resolve`. */
|
||||
export function joinPath(parent: string, name: string): string {
|
||||
return `${parent === '/' ? '' : parent}/${name}`;
|
||||
}
|
||||
|
||||
function normalise(value: string): string {
|
||||
return value.normalize('NFC').replace(/\s+/g, ' ').trim().toLocaleLowerCase('de');
|
||||
}
|
||||
|
||||
export interface WalkEntry {
|
||||
path: string;
|
||||
/** The listed directory this entry came from; renderers group on it. */
|
||||
parentPath: string;
|
||||
depth: number;
|
||||
parent: DirectoryRef;
|
||||
directory?: { ref: DirectoryRef; name: string; id: string };
|
||||
file?: FmFile;
|
||||
}
|
||||
|
||||
export interface WalkResult {
|
||||
entries: WalkEntry[];
|
||||
/** Directories that were listed. */
|
||||
visited: number;
|
||||
/** Set when the budget ran out before the walk finished. */
|
||||
truncated: boolean;
|
||||
failures: { path: string; reason: string }[];
|
||||
}
|
||||
|
||||
interface CachedListing {
|
||||
at: number;
|
||||
listing: Promise<FmListing>;
|
||||
}
|
||||
|
||||
/** A listing is reused this long: long enough for ls→read, short enough to stay live. */
|
||||
const LISTING_TTL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* The file manager as a tree of paths:
|
||||
*
|
||||
* / the four areas
|
||||
* /my/… Persönliche Dateien
|
||||
* /courses/<course>/… Kurs-Dateien
|
||||
* /teams/<team>/… Team-Dateien
|
||||
* /shared/… Geteilte Dateien (flat)
|
||||
*
|
||||
* Names are the file manager's own. Because course names contain `/` in real
|
||||
* data ("LF07 - FIA24A/B - Sb/Ha"), a path is not split naively: resolution
|
||||
* tries joining consecutive segments into one name and backtracks when a
|
||||
* shorter reading leads nowhere. Any segment may also be an id instead of a
|
||||
* name, which is always unambiguous and is what the listings print alongside.
|
||||
*/
|
||||
export class FileManager {
|
||||
private readonly client: SchulcloudClient;
|
||||
private readonly cache = new Map<string, CachedListing>();
|
||||
|
||||
constructor(client: SchulcloudClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/** Lists one directory. The root is synthetic and costs nothing. */
|
||||
async list(ref: DirectoryRef): Promise<FmListing> {
|
||||
const page = pageFor(ref);
|
||||
if (!page) {
|
||||
return { directories: FILE_AREAS.map((entry) => ({ id: entry.area, name: entry.area })), files: [] };
|
||||
}
|
||||
|
||||
const cached = this.cache.get(page);
|
||||
if (cached && Date.now() - cached.at < LISTING_TTL_MS) return cached.listing;
|
||||
|
||||
const listing = this.client.getFileManagerPage(page).then(parseFileListing);
|
||||
this.cache.set(page, { at: Date.now(), listing });
|
||||
// A failure must not be served from the cache for the next minute.
|
||||
listing.catch(() => this.cache.delete(page));
|
||||
return listing;
|
||||
}
|
||||
|
||||
/** Resolves a path to a directory or a file. */
|
||||
async resolve(path: string): Promise<FsNode> {
|
||||
const segments = splitPath(path);
|
||||
if (segments.length === 0) return { kind: 'directory', path: '/', ref: {}, name: '/' };
|
||||
|
||||
const first = segments[0] as string;
|
||||
const area = FILE_AREAS.find((entry) => entry.aliases.includes(normalise(first)) || entry.area === first);
|
||||
if (!area) {
|
||||
throw new FsError(
|
||||
'not_found',
|
||||
`"${first}" is not a file area. The root holds ${FILE_AREAS.map((entry) => `/${entry.area} (${entry.label})`).join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const ref: DirectoryRef = { area: area.area };
|
||||
const found = await this.descend(ref, `/${area.area}`, segments.slice(1));
|
||||
return found;
|
||||
}
|
||||
|
||||
private async descend(ref: DirectoryRef, path: string, rest: string[]): Promise<FsNode> {
|
||||
if (rest.length === 0) return { kind: 'directory', path, ref, name: path.split('/').pop() || '/' };
|
||||
|
||||
const listing = await this.list(ref);
|
||||
const readings = candidateReadings(listing, rest);
|
||||
if (readings.length === 0) throw notFound(listing, rest[0] as string, path);
|
||||
|
||||
let lastError: FsError | undefined;
|
||||
for (const reading of readings) {
|
||||
if (reading.matches.length > 1) {
|
||||
const options = reading.matches.map((match) => `"${match.entry.name}" (\`${match.entry.id}\`)`).join(', ');
|
||||
throw new FsError(
|
||||
'ambiguous',
|
||||
`${path} holds more than one entry named "${reading.name}": ${options}. Use the id as that path segment instead.`,
|
||||
);
|
||||
}
|
||||
const match = reading.matches[0] as { kind: 'directory'; entry: FmDirectory } | { kind: 'file'; entry: FmFile };
|
||||
const remaining = rest.slice(reading.consumed);
|
||||
const nextPath = joinPath(path, match.entry.name);
|
||||
|
||||
if (match.kind === 'file') {
|
||||
if (remaining.length === 0) return { kind: 'file', path: nextPath, parent: ref, file: match.entry };
|
||||
lastError = new FsError('not_a_directory', `${nextPath} is a file, not a folder.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.descend(childRef(ref, match.entry), nextPath, remaining);
|
||||
} catch (error) {
|
||||
// A shorter reading of a name containing "/" can lead nowhere while a
|
||||
// longer one resolves; only give up once every reading has failed.
|
||||
if (error instanceof FsError && error.code !== 'ambiguous' && error.code !== 'not_navigable') {
|
||||
lastError = error;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw lastError ?? notFound(listing, rest[0] as string, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks a directory breadth-first, listing at most `maxDirectories` of them.
|
||||
*
|
||||
* Every listing is one page fetch, so the budget is the cost. A folder that
|
||||
* cannot be read is recorded and skipped, never silently dropped.
|
||||
*/
|
||||
async walk(
|
||||
start: { path: string; ref: DirectoryRef },
|
||||
options: { maxDepth: number; maxDirectories: number; concurrency?: number },
|
||||
): Promise<WalkResult> {
|
||||
const entries: WalkEntry[] = [];
|
||||
const failures: { path: string; reason: string }[] = [];
|
||||
let visited = 0;
|
||||
let truncated = false;
|
||||
|
||||
let frontier: { path: string; ref: DirectoryRef; depth: number }[] = [{ ...start, depth: 0 }];
|
||||
while (frontier.length > 0) {
|
||||
const next: typeof frontier = [];
|
||||
const batch = frontier;
|
||||
frontier = [];
|
||||
|
||||
const concurrency = Math.max(1, options.concurrency ?? 3);
|
||||
for (let i = 0; i < batch.length; i += concurrency) {
|
||||
const slice = batch.slice(i, i + concurrency);
|
||||
await Promise.all(
|
||||
slice.map(async (node) => {
|
||||
// The root is synthetic and costs no request, so it does not count.
|
||||
if (pageFor(node.ref)) {
|
||||
if (visited >= options.maxDirectories) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
visited++;
|
||||
}
|
||||
let listing: FmListing;
|
||||
try {
|
||||
listing = await this.list(node.ref);
|
||||
} catch (error) {
|
||||
failures.push({ path: node.path, reason: error instanceof Error ? error.message : String(error) });
|
||||
return;
|
||||
}
|
||||
// The root's areas keep their defined order; real folders sort by name.
|
||||
const directories = pageFor(node.ref) ? sortByName(listing.directories) : listing.directories;
|
||||
for (const directory of directories) {
|
||||
const path = joinPath(node.path, directory.name);
|
||||
let ref: DirectoryRef | undefined;
|
||||
try {
|
||||
ref = childRef(node.ref, directory);
|
||||
} catch {
|
||||
ref = undefined; // shared folders: listed, never opened
|
||||
}
|
||||
entries.push({
|
||||
path,
|
||||
parentPath: node.path,
|
||||
depth: node.depth + 1,
|
||||
parent: node.ref,
|
||||
directory: { ref: ref ?? node.ref, name: directory.name, id: directory.id },
|
||||
});
|
||||
if (ref && node.depth + 1 < options.maxDepth) next.push({ path, ref, depth: node.depth + 1 });
|
||||
}
|
||||
for (const file of sortByName(listing.files)) {
|
||||
entries.push({
|
||||
path: joinPath(node.path, file.name),
|
||||
parentPath: node.path,
|
||||
depth: node.depth + 1,
|
||||
parent: node.ref,
|
||||
file,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
|
||||
// Unsorted across directories on purpose: sorting whole path strings
|
||||
// interleaves a folder's children with a sibling that shares its prefix
|
||||
// ("Sub/…" against "Sub - Kopie"). Renderers group on `parentPath`.
|
||||
return { entries, visited, truncated, failures };
|
||||
}
|
||||
|
||||
download(file: Pick<FmFile, 'id' | 'name'>): Promise<DownloadedFile> {
|
||||
return this.client.downloadFileManagerFile(file.id, file.name);
|
||||
}
|
||||
}
|
||||
|
||||
type Match = { kind: 'directory'; entry: FmDirectory } | { kind: 'file'; entry: FmFile };
|
||||
|
||||
/**
|
||||
* Every way the next path segments can name an entry, shortest first.
|
||||
*
|
||||
* `rest[0]`, then `rest[0]/rest[1]`, and so on — so a course called
|
||||
* "LF07 - FIA24A/B - Sb/Ha" resolves even when typed plainly. An id segment
|
||||
* matches too. Exact names are preferred over case-insensitive ones.
|
||||
*/
|
||||
function candidateReadings(listing: FmListing, rest: string[]): { name: string; consumed: number; matches: Match[] }[] {
|
||||
const all: Match[] = [
|
||||
...listing.directories.map((entry) => ({ kind: 'directory' as const, entry })),
|
||||
...listing.files.map((entry) => ({ kind: 'file' as const, entry })),
|
||||
];
|
||||
|
||||
const first = rest[0] as string;
|
||||
if (/^[0-9a-f]{24}$/i.test(first)) {
|
||||
const byId = all.filter((match) => match.entry.id.toLowerCase() === first.toLowerCase());
|
||||
if (byId.length > 0) return [{ name: first, consumed: 1, matches: byId }];
|
||||
}
|
||||
|
||||
const readings: { name: string; consumed: number; matches: Match[] }[] = [];
|
||||
for (const strict of [true, false]) {
|
||||
for (let take = 1; take <= rest.length; take++) {
|
||||
const name = rest.slice(0, take).join('/');
|
||||
const matches = all.filter((match) =>
|
||||
strict ? match.entry.name.trim() === name : normalise(match.entry.name) === normalise(name),
|
||||
);
|
||||
if (matches.length > 0 && !readings.some((reading) => reading.consumed === take)) {
|
||||
readings.push({ name, consumed: take, matches });
|
||||
}
|
||||
}
|
||||
if (readings.length > 0) break;
|
||||
}
|
||||
return readings;
|
||||
}
|
||||
|
||||
function notFound(listing: FmListing, segment: string, path: string): FsError {
|
||||
const names = [...listing.directories.map((entry) => `${entry.name}/`), ...listing.files.map((entry) => entry.name)];
|
||||
const needle = normalise(segment);
|
||||
const close = names.filter((name) => normalise(name).includes(needle) || needle.includes(normalise(name).replace(/\/$/, '')));
|
||||
const hint =
|
||||
close.length > 0
|
||||
? ` Did you mean: ${close.slice(0, 5).map((name) => `"${name}"`).join(', ')}?`
|
||||
: names.length > 0
|
||||
? ` It holds ${names.length} entr${names.length === 1 ? 'y' : 'ies'}; list it with fs_list.`
|
||||
: ' It is empty.';
|
||||
return new FsError('not_found', `No "${segment}" in ${path}.${hint}`);
|
||||
}
|
||||
|
||||
function sortByName<T extends { name: string }>(items: T[]): T[] {
|
||||
return [...items].sort((a, b) => compareNames(a.name, b.name));
|
||||
}
|
||||
|
||||
/** Name order as a person expects it: German collation, "Blatt 2" before "Blatt 10". */
|
||||
export function compareNames(a: string, b: string): number {
|
||||
return a.localeCompare(b, 'de', { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive substring; or, when the pattern uses * or ?, a glob that
|
||||
* must match the whole name, as `find -name` does. Exported for testing.
|
||||
*/
|
||||
export function nameMatcher(pattern: string): (name: string) => boolean {
|
||||
const needle = pattern.normalize('NFC').toLocaleLowerCase('de');
|
||||
if (!/[*?]/.test(needle)) return (name) => name.normalize('NFC').toLocaleLowerCase('de').includes(needle);
|
||||
const source = needle
|
||||
.split('')
|
||||
.map((char) => (char === '*' ? '.*' : char === '?' ? '.' : char.replace(/[.+^${}()|[\]\\]/g, '\\$&')))
|
||||
.join('');
|
||||
const regex = new RegExp(`^${source}$`, 'i');
|
||||
return (name) => regex.test(name.normalize('NFC'));
|
||||
}
|
||||
84
src/core/lesson-page.ts
Normal file
84
src/core/lesson-page.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { Config } from '../config.ts';
|
||||
import { decodeEntities } from './text.ts';
|
||||
import type { LessonLinkedTask } from './types.ts';
|
||||
|
||||
/**
|
||||
* Recovers the ids of tasks that hang off a topic ("Thema").
|
||||
*
|
||||
* `GET /api/v3/lessons/{id}/tasks` returns the topic's tasks — with their name,
|
||||
* description and dates, but **no id**: `LessonLinkedTaskResponse` has no id
|
||||
* field at all, by design rather than by omission. The course page is no help
|
||||
* either, because a topic-attached task is not a task element there; the topic
|
||||
* reports only `numberOfPublishedTasks`.
|
||||
*
|
||||
* The consequence is that those tasks were unreachable: absent from both task
|
||||
* lists once past due (open excludes them, finished only holds what the student
|
||||
* ticked off), absent from the course page, and unidentifiable from the topic.
|
||||
* Their submissions, and so their grades, could not be read at all. On the
|
||||
* account this was written for that is 18 of 60 tasks.
|
||||
*
|
||||
* The legacy topic page links each task as `/homework/{id}`, so it carries the
|
||||
* mapping the API withholds. Like the homework-page scrape this authenticates
|
||||
* by `jwt` **cookie**, hangs off an accessibility attribute rather than
|
||||
* presentation markup, and degrades to an empty list — a markup change costs
|
||||
* the ids again, never an error.
|
||||
*/
|
||||
|
||||
export interface LessonTaskLink {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function fetchLessonTaskLinks(
|
||||
config: Config,
|
||||
courseId: string,
|
||||
lessonId: string,
|
||||
): Promise<LessonTaskLink[]> {
|
||||
const url = `${config.baseUrl}/courses/${encodeURIComponent(courseId)}/topics/${encodeURIComponent(lessonId)}`;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { Cookie: `jwt=${config.jwt}`, Accept: 'text/html' },
|
||||
signal: AbortSignal.timeout(config.requestTimeoutMs),
|
||||
redirect: 'follow',
|
||||
});
|
||||
// Redirected away means the cookie was not accepted; there is nothing to
|
||||
// parse and nothing worth raising.
|
||||
if (!response.ok || !new URL(response.url).hostname.endsWith(new URL(config.baseUrl).hostname)) {
|
||||
return [];
|
||||
}
|
||||
return parseLessonTaskLinks(await response.text());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Exported for testing: the parsing is pure and deserves fixtures, not a network. */
|
||||
export function parseLessonTaskLinks(html: string): LessonTaskLink[] {
|
||||
// `aria-label="Details der Aufgabe: 'name'"` exists for screen readers, which
|
||||
// makes it far steadier than the surrounding layout.
|
||||
const pattern = /<a href="\/homework\/([0-9a-f]{24})"[^>]*aria-label="[^"']*'([^']*)'/g;
|
||||
const found: LessonTaskLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const match of html.matchAll(pattern)) {
|
||||
const id = match[1] ?? '';
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
found.push({ id, name: decodeEntities(match[2] ?? '').trim() });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairs the API's id-less task bodies with the ids scraped from the topic page.
|
||||
*
|
||||
* Matching is by name, which is what both sides agree on. A name the page does
|
||||
* not account for yields a task without an id: still worth reporting (it is
|
||||
* visible to the user), just not something the id-taking tools can open.
|
||||
*/
|
||||
export function withScrapedIds(tasks: LessonLinkedTask[], links: LessonTaskLink[]): LessonLinkedTask[] {
|
||||
const byName = new Map(links.map((link) => [link.name, link.id]));
|
||||
return tasks.map((task) => {
|
||||
const id = task.id ?? byName.get(task.name?.trim() ?? '');
|
||||
return id ? { ...task, id } : task;
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Snapshot } from './crawl.ts';
|
||||
import type { CrawledBoard, Snapshot } from './crawl.ts';
|
||||
import { h5pSearchText } from './h5p.ts';
|
||||
import { noteSearchText, noteSections } from './notes.ts';
|
||||
import { matchesAll, snippet, tokenize } from './text.ts';
|
||||
|
||||
/**
|
||||
@@ -17,10 +19,53 @@ export interface Hit {
|
||||
where: string;
|
||||
/** Id to pass to a follow-up tool, with the tool that takes it. */
|
||||
targetId: string;
|
||||
targetKind: 'course' | 'board' | 'lesson' | 'task' | 'file';
|
||||
targetKind: 'course' | 'room' | 'board' | 'lesson' | 'task' | 'file' | 'note';
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a container's boards. Shared by courses and rooms so a hit in a room
|
||||
* looks and behaves exactly like a hit in a course.
|
||||
*/
|
||||
function matchBoards(
|
||||
boards: CrawledBoard[],
|
||||
base: { courseId: string; courseTitle: string },
|
||||
terms: string[],
|
||||
push: (hit: Hit) => void,
|
||||
): void {
|
||||
for (const board of boards) {
|
||||
if (matchesAll(board.title, terms)) {
|
||||
push({ ...base, where: 'board title', targetId: board.id, targetKind: 'board', snippet: board.title });
|
||||
}
|
||||
// Match per card, so the snippet points at the right part of the board.
|
||||
for (const column of board.board.columns) {
|
||||
for (const card of column.cards) {
|
||||
const parts = [card.title];
|
||||
for (const element of card.elements) {
|
||||
if (element.text) parts.push(element.text);
|
||||
// Pad contents are searched by the index; without this the live
|
||||
// crawl path would quietly disagree with it.
|
||||
if (element.padText) parts.push(element.padText);
|
||||
// Same corpus as the index, or a live search would disagree with it.
|
||||
if (element.h5p) parts.push(h5pSearchText(element.h5p));
|
||||
if (element.url) parts.push(element.url);
|
||||
for (const file of element.files) parts.push(file.name);
|
||||
}
|
||||
const haystack = parts.filter(Boolean).join('\n');
|
||||
if (matchesAll(haystack, terms)) {
|
||||
push({
|
||||
...base,
|
||||
where: `board "${board.title}" → card "${card.title}"`,
|
||||
targetId: board.id,
|
||||
targetKind: 'board',
|
||||
snippet: snippet(haystack, terms),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function searchSnapshot(snapshot: Snapshot, query: string, limit = 50): Hit[] {
|
||||
const terms = tokenize(query);
|
||||
if (terms.length === 0) return [];
|
||||
@@ -37,32 +82,7 @@ export function searchSnapshot(snapshot: Snapshot, query: string, limit = 50): H
|
||||
push({ ...base, where: 'course title', targetId: course.course.id, targetKind: 'course', snippet: course.title });
|
||||
}
|
||||
|
||||
for (const board of course.boards) {
|
||||
if (matchesAll(board.title, terms)) {
|
||||
push({ ...base, where: 'board title', targetId: board.id, targetKind: 'board', snippet: board.title });
|
||||
}
|
||||
// Match per card, so the snippet points at the right part of the board.
|
||||
for (const column of board.board.columns) {
|
||||
for (const card of column.cards) {
|
||||
const parts = [card.title];
|
||||
for (const element of card.elements) {
|
||||
if (element.text) parts.push(element.text);
|
||||
if (element.url) parts.push(element.url);
|
||||
for (const file of element.files) parts.push(file.name);
|
||||
}
|
||||
const haystack = parts.filter(Boolean).join('\n');
|
||||
if (matchesAll(haystack, terms)) {
|
||||
push({
|
||||
...base,
|
||||
where: `board "${board.title}" → card "${card.title}"`,
|
||||
targetId: board.id,
|
||||
targetKind: 'board',
|
||||
snippet: snippet(haystack, terms),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
matchBoards(course.boards, base, terms, push);
|
||||
|
||||
for (const lesson of course.lessons) {
|
||||
const haystack = `${lesson.name}\n${lesson.text}`;
|
||||
@@ -85,6 +105,52 @@ export function searchSnapshot(snapshot: Snapshot, query: string, limit = 50): H
|
||||
}
|
||||
}
|
||||
|
||||
// Rooms carry boards and nothing else, so they reuse the same matcher; a hit
|
||||
// reads the same whether the board hangs off a course or a room.
|
||||
for (const room of snapshot.rooms) {
|
||||
const base = { courseId: room.id, courseTitle: room.name };
|
||||
if (matchesAll(room.name, terms)) {
|
||||
push({ ...base, where: 'room title', targetId: room.id, targetKind: 'room', snippet: room.name });
|
||||
}
|
||||
matchBoards(room.boards, base, terms, push);
|
||||
}
|
||||
|
||||
// The user's own notes. `courseId` is the subject rather than an id: nothing
|
||||
// follows a note back to a course, and the subject is what makes the hit
|
||||
// readable — "Deutsch — my note" rather than a bare path.
|
||||
for (const note of snapshot.notes) {
|
||||
// Per lesson where the note has lessons, exactly as the index does it —
|
||||
// otherwise fresh=true would report "Monday" where the index reports
|
||||
// "Deutsch, Monday", and the two paths would disagree about the same file.
|
||||
const sections = noteSections(note);
|
||||
if (sections.length > 0) {
|
||||
for (const section of sections) {
|
||||
const haystack = [section.heading, section.text].filter(Boolean).join('\n');
|
||||
if (!matchesAll(haystack, terms)) continue;
|
||||
hits.push({
|
||||
courseId: note.courseId ?? '',
|
||||
courseTitle: section.subject ?? note.subject ?? 'Notizen',
|
||||
where: `my own note, ${section.heading}${note.date ? `, ${note.date}` : ''}`,
|
||||
targetId: note.path,
|
||||
targetKind: 'note',
|
||||
snippet: snippet(haystack, terms),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const haystack = noteSearchText(note);
|
||||
if (!matchesAll(haystack, terms)) continue;
|
||||
hits.push({
|
||||
courseId: note.courseId ?? '',
|
||||
courseTitle: note.subject ?? 'Notizen',
|
||||
where: `my own note${note.date ? `, ${note.date}` : ''}`,
|
||||
targetId: note.path,
|
||||
targetKind: 'note',
|
||||
snippet: snippet(haystack, terms),
|
||||
});
|
||||
}
|
||||
|
||||
for (const file of snapshot.files) {
|
||||
if (matchesAll(file.record.name, terms)) {
|
||||
hits.push({
|
||||
|
||||
654
src/core/notes.ts
Normal file
654
src/core/notes.ts
Normal file
@@ -0,0 +1,654 @@
|
||||
import { readdir, readFile, stat, mkdir, writeFile, appendFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { schoolToday } from './dates.ts';
|
||||
import { resolveWithin, safeComponent } from './paths.ts';
|
||||
|
||||
/**
|
||||
* The user's own lesson notes: a directory of Markdown files.
|
||||
*
|
||||
* This is the one store here that Schulcloud and WebUntis know nothing about —
|
||||
* what the person in the room wrote down. It exists because the two upstreams
|
||||
* between them still do not answer "what did the teacher actually say", and a
|
||||
* note taken in the lesson is often the only record of it.
|
||||
*
|
||||
* **Plain files, not a table.** The notes have to be writable from a phone in a
|
||||
* classroom and readable when Postgres is down, so the files are the truth and
|
||||
* the index is only a view of them — the same split as `file_texts` and the
|
||||
* mirror. It also makes migrating in a pile of exported Apple Notes a matter of
|
||||
* writing files, and makes the whole store greppable, diffable and syncable by
|
||||
* anything the user already runs.
|
||||
*
|
||||
* Frontmatter is a deliberately small YAML subset (scalars and inline lists),
|
||||
* parsed here rather than by a dependency: notes are hand-written, so a strict
|
||||
* parser that rejects a file is worse than a lax one that keeps the body. A
|
||||
* file with no frontmatter at all is a valid note.
|
||||
*/
|
||||
|
||||
/** Extensions treated as notes. Anything else in the directory is ignored. */
|
||||
const NOTE_EXTENSIONS = ['.md', '.markdown', '.txt'];
|
||||
|
||||
/**
|
||||
* Caps. A notes directory is user-controlled, but it may also be a synced
|
||||
* folder that has just acquired somebody's 400 MB export, and a crawl must not
|
||||
* turn that into an out-of-memory.
|
||||
*/
|
||||
const MAX_NOTE_BYTES = 512 * 1024;
|
||||
const MAX_NOTES = 5_000;
|
||||
const MAX_DEPTH = 8;
|
||||
|
||||
export interface NoteDoc {
|
||||
/**
|
||||
* Path relative to the notes root — `Deutsch/2026-09-15 Erörterung.md`.
|
||||
* This is the note's id: there is no other, and it is what `get_note` takes.
|
||||
*/
|
||||
path: string;
|
||||
title: string;
|
||||
/** The school day the note belongs to, `YYYY-MM-DD`, when it could be determined. */
|
||||
date?: string;
|
||||
/** Free text as the user writes it — "Deutsch", "LF07". Not a Schulcloud id. */
|
||||
subject?: string;
|
||||
/** A Schulcloud course id, when the note names one, so search can group by course. */
|
||||
courseId?: string;
|
||||
tags: string[];
|
||||
/** Where the note came from: `apple-notes`, `add_note`, or absent for a hand-written file. */
|
||||
source?: string;
|
||||
/** The body, without the frontmatter block. */
|
||||
text: string;
|
||||
/** Last write to the file, ISO. Not the lesson date — see `date` for that. */
|
||||
modifiedAt: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface NoteFrontmatter {
|
||||
title?: string;
|
||||
date?: string;
|
||||
subject?: string;
|
||||
courseId?: string;
|
||||
tags?: string[];
|
||||
source?: string;
|
||||
/** Anything else the file carried, preserved so a round trip loses nothing. */
|
||||
extra?: Record<string, string>;
|
||||
}
|
||||
|
||||
// --- reading -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Every note under `root`, newest lesson first.
|
||||
*
|
||||
* Never throws for a missing root: a notes directory that has not been created
|
||||
* yet is an empty one, and the tools say so far better than a crawl that dies.
|
||||
*/
|
||||
export async function readNotes(root: string): Promise<NoteDoc[]> {
|
||||
const paths = await listNotePaths(root);
|
||||
const notes: NoteDoc[] = [];
|
||||
for (const relative of paths) {
|
||||
const note = await readNoteAt(root, relative).catch(() => undefined);
|
||||
if (note) notes.push(note);
|
||||
}
|
||||
return notes.sort(byNewest);
|
||||
}
|
||||
|
||||
/** Relative paths of the note files under `root`, sorted for a stable order. */
|
||||
export async function listNotePaths(root: string): Promise<string[]> {
|
||||
const found: string[] = [];
|
||||
|
||||
const walk = async (relative: string, depth: number): Promise<void> => {
|
||||
if (depth > MAX_DEPTH || found.length >= MAX_NOTES) return;
|
||||
const absolute = relative ? resolveWithin(root, relative) : root;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(absolute, { withFileTypes: true });
|
||||
} catch {
|
||||
// A root that does not exist yet, or a folder we may not read: an
|
||||
// unreadable corner must not cost the notes that are readable.
|
||||
return;
|
||||
}
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
// Dotfiles are the sync tools' own business (.obsidian, .git, .stfolder)
|
||||
// and never a note.
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const child = relative ? `${relative}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) await walk(child, depth + 1);
|
||||
else if (isNoteFile(entry.name) && found.length < MAX_NOTES) found.push(child);
|
||||
}
|
||||
};
|
||||
|
||||
await walk('', 0);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** One note by its relative path. Throws `NoteNotFound` when there is none. */
|
||||
export async function readNoteAt(root: string, relative: string): Promise<NoteDoc> {
|
||||
const absolute = resolveWithin(root, normalizeRelative(relative));
|
||||
let info;
|
||||
try {
|
||||
info = await stat(absolute);
|
||||
} catch {
|
||||
throw new NoteNotFound(relative);
|
||||
}
|
||||
if (!info.isFile()) throw new NoteNotFound(relative);
|
||||
if (info.size > MAX_NOTE_BYTES) {
|
||||
throw new Error(
|
||||
`Note ${relative} is ${Math.round(info.size / 1024)} KB, past the ${MAX_NOTE_BYTES / 1024} KB limit for a note.`,
|
||||
);
|
||||
}
|
||||
const raw = await readFile(absolute, 'utf8');
|
||||
return parseNote(normalizeRelative(relative), raw, { modifiedAt: info.mtime.toISOString(), bytes: info.size });
|
||||
}
|
||||
|
||||
export class NoteNotFound extends Error {
|
||||
readonly path: string;
|
||||
|
||||
constructor(path: string) {
|
||||
super(`No note at "${path}".`);
|
||||
this.name = 'NoteNotFound';
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A file's text as a note.
|
||||
*
|
||||
* Pure, so the whole frontmatter/title/date story is testable without a disk.
|
||||
*/
|
||||
export function parseNote(
|
||||
relative: string,
|
||||
raw: string,
|
||||
stamp: { modifiedAt: string; bytes: number },
|
||||
): NoteDoc {
|
||||
const { front, body } = splitFrontmatter(raw);
|
||||
const fileName = relative.split('/').pop() ?? relative;
|
||||
return {
|
||||
path: relative,
|
||||
title: front.title || headingTitle(body) || titleFromFileName(fileName),
|
||||
// Frontmatter first, then a date the filename starts with. Never the
|
||||
// file's mtime: an import writes every note today, and dating a year of
|
||||
// lessons "today" would make the whole store useless for "what did we do
|
||||
// before the test".
|
||||
...pick('date', front.date ?? dateFromFileName(fileName)),
|
||||
...pick('subject', front.subject ?? subjectFromPath(relative)),
|
||||
...pick('courseId', front.courseId),
|
||||
...pick('source', front.source),
|
||||
tags: front.tags ?? [],
|
||||
text: body.trim(),
|
||||
modifiedAt: stamp.modifiedAt,
|
||||
bytes: stamp.bytes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits `---\nkey: value\n---\n` off the front.
|
||||
*
|
||||
* Only a leading block counts, and only when it closes: a note that happens to
|
||||
* begin with a horizontal rule keeps its text rather than losing half of it.
|
||||
*/
|
||||
export function splitFrontmatter(raw: string): { front: NoteFrontmatter; body: string } {
|
||||
const text = raw.replace(/^\ufeff/, '');
|
||||
const open = /^---[ \t]*\r?\n/.exec(text);
|
||||
if (!open) return { front: {}, body: text };
|
||||
const close = /\r?\n---[ \t]*(\r?\n|$)/.exec(text.slice(open[0].length - 1));
|
||||
if (!close) return { front: {}, body: text };
|
||||
|
||||
const end = open[0].length - 1 + close.index;
|
||||
const block = text.slice(open[0].length, end);
|
||||
const rest = text.slice(end + close[0].length);
|
||||
|
||||
const front: NoteFrontmatter = {};
|
||||
const extra: Record<string, string> = {};
|
||||
const lines = block.split(/\r?\n/);
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const match = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line.trim());
|
||||
if (!match) continue;
|
||||
const key = match[1]!.toLowerCase();
|
||||
let value = unquote(match[2]!.trim());
|
||||
// `tags:` followed by indented `- item` lines is how Obsidian and most
|
||||
// YAML front ends write a list, and reading only the inline `[a, b]`
|
||||
// form dropped every tag such an editor had written.
|
||||
if (!value) {
|
||||
const items = blockList(lines, index);
|
||||
if (items.length === 0) continue;
|
||||
value = `[${items.join(', ')}]`;
|
||||
}
|
||||
switch (key) {
|
||||
case 'title':
|
||||
front.title = value;
|
||||
break;
|
||||
case 'date':
|
||||
front.date = normalizeDate(value);
|
||||
break;
|
||||
case 'subject':
|
||||
case 'fach':
|
||||
front.subject = value;
|
||||
break;
|
||||
case 'courseid':
|
||||
case 'course':
|
||||
front.courseId = value;
|
||||
break;
|
||||
case 'source':
|
||||
front.source = value;
|
||||
break;
|
||||
case 'tags':
|
||||
front.tags = parseList(value);
|
||||
break;
|
||||
default:
|
||||
extra[key] = value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(extra).length > 0) front.extra = extra;
|
||||
return { front, body: rest };
|
||||
}
|
||||
|
||||
// --- writing -------------------------------------------------------------
|
||||
|
||||
export interface NoteInput {
|
||||
title: string;
|
||||
text: string;
|
||||
/** The lesson's day. Defaults to today in the school's timezone. */
|
||||
date?: string;
|
||||
subject?: string;
|
||||
courseId?: string;
|
||||
tags?: string[];
|
||||
source?: string;
|
||||
/** Write here instead of deriving a path from subject, date and title. */
|
||||
path?: string;
|
||||
/**
|
||||
* Add to the note at that path if it already exists, rather than creating a
|
||||
* second one. This is what makes a lesson's notes accumulate in one file as
|
||||
* they are taken, which is how anyone actually takes them.
|
||||
*/
|
||||
append?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a note, or appends to one.
|
||||
*
|
||||
* Every component of the path goes through `safeComponent`: the title and
|
||||
* subject arrive from a tool call, so they are untrusted input that becomes a
|
||||
* filename, exactly as course titles do in the mirror.
|
||||
*/
|
||||
export async function writeNote(root: string, input: NoteInput): Promise<{ note: NoteDoc; appended: boolean }> {
|
||||
const date = input.date ?? schoolToday();
|
||||
// Appending is about a *lesson*, not about a title: "note this down too" in
|
||||
// the middle of Tuesday's German lesson means the note already open for
|
||||
// Tuesday and German, whatever it happens to be called. Deriving the path
|
||||
// from the new title instead would start a second note every time, which is
|
||||
// the one thing append exists to prevent.
|
||||
const relative = input.path
|
||||
? normalizeRelative(input.path)
|
||||
: ((input.append ? await noteForLesson(root, date, input.subject) : undefined) ??
|
||||
notePathFor({ date, subject: input.subject, title: input.title }));
|
||||
const absolute = resolveWithin(root, relative);
|
||||
|
||||
const existing = await stat(absolute).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
if (existing && input.append) {
|
||||
// A heading rather than a bare paragraph, so a note built from four
|
||||
// appends still reads as four things and not as one run-on.
|
||||
await appendFile(absolute, `\n\n## ${input.title}\n\n${input.text.trim()}\n`, 'utf8');
|
||||
return { note: await readNoteAt(root, relative), appended: true };
|
||||
}
|
||||
|
||||
// Never overwrite: a note is the only copy of what someone wrote down, and a
|
||||
// second note with the same title on the same day is a normal thing to have.
|
||||
const target = existing ? await freePath(root, relative) : relative;
|
||||
await mkdir(dirname(resolveWithin(root, target)), { recursive: true });
|
||||
await writeFile(
|
||||
resolveWithin(root, target),
|
||||
renderNote(
|
||||
{
|
||||
title: input.title,
|
||||
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 { 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. */
|
||||
export function renderNote(front: NoteFrontmatter, body: string): string {
|
||||
const lines = [
|
||||
front.title !== undefined && `title: ${quote(front.title)}`,
|
||||
front.date !== undefined && `date: ${front.date}`,
|
||||
front.subject !== undefined && `subject: ${quote(front.subject)}`,
|
||||
front.courseId !== undefined && `courseId: ${front.courseId}`,
|
||||
front.tags && front.tags.length > 0 && `tags: [${front.tags.map((tag) => quote(tag)).join(', ')}]`,
|
||||
front.source !== undefined && `source: ${quote(front.source)}`,
|
||||
...Object.entries(front.extra ?? {}).map(([key, value]) => `${key}: ${quote(value)}`),
|
||||
].filter((line): line is string => typeof line === 'string');
|
||||
return `---\n${lines.join('\n')}\n---\n\n${body.trim()}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a new note goes: `Deutsch/2026-09-15 Erörterung.md`.
|
||||
*
|
||||
* Subject-first because that is how anyone looks for a note by hand, and the
|
||||
* date leads the filename so a folder sorts chronologically in every file
|
||||
* browser there is.
|
||||
*/
|
||||
export function notePathFor(input: { date: string; subject?: string; title: string }): string {
|
||||
const folder = safeComponent(input.subject ?? 'Allgemein', 'Allgemein');
|
||||
const name = safeComponent(`${input.date} ${input.title}`, input.date);
|
||||
return `${folder}/${name}.md`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The note already written for this day and subject, if there is one.
|
||||
*
|
||||
* The newest by path, so a day that somehow grew two notes still gets the one
|
||||
* a person would reach for.
|
||||
*/
|
||||
async function noteForLesson(root: string, date: string, subject: string | undefined): Promise<string | undefined> {
|
||||
const wanted = subject?.trim().toLowerCase();
|
||||
const candidates = (await readNotes(root)).filter(
|
||||
(note) => note.date === date && (note.subject ?? '').toLowerCase() === (wanted ?? ''),
|
||||
);
|
||||
return candidates[0]?.path;
|
||||
}
|
||||
|
||||
/** `note.md` → `note 2.md`, for the day someone titles two notes the same. */
|
||||
async function freePath(root: string, relative: string): Promise<string> {
|
||||
const dot = relative.lastIndexOf('.');
|
||||
const stem = dot > 0 ? relative.slice(0, dot) : relative;
|
||||
const ext = dot > 0 ? relative.slice(dot) : '';
|
||||
for (let n = 2; n < 100; n++) {
|
||||
const candidate = `${stem} ${n}${ext}`;
|
||||
const taken = await stat(resolveWithin(root, candidate)).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
if (!taken) return candidate;
|
||||
}
|
||||
throw new Error(`Too many notes named like ${relative}.`);
|
||||
}
|
||||
|
||||
// --- sections: one note per school day, one heading per lesson -------------
|
||||
|
||||
/**
|
||||
* A `##` section of a note, which for a day note is one lesson.
|
||||
*
|
||||
* The shape the notes page writes — one note per school day, titled with the
|
||||
* date, a heading per timetable lesson, prose and lists and tables beneath —
|
||||
* is the shape people actually take notes in, and it is the reason this exists.
|
||||
* Indexing such a note whole would make every hit read "my note, Monday" and
|
||||
* lose the one thing that makes it findable: which lesson it was.
|
||||
*/
|
||||
export interface NoteSection {
|
||||
/** The heading text, without its `##`. */
|
||||
heading: string;
|
||||
/** The subject read out of the heading, when there is one. */
|
||||
subject?: string;
|
||||
/** The body under the heading, subheadings included. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `##` sections of a note, in order. Empty when it has none — a subject
|
||||
* note from the import is one piece of prose, and has to stay that way.
|
||||
*
|
||||
* Fenced code blocks are skipped, or a `## comment` inside one would split the
|
||||
* note where nobody wrote a heading.
|
||||
*/
|
||||
export function noteSections(note: NoteDoc): NoteSection[] {
|
||||
const sections: NoteSection[] = [];
|
||||
let current: { heading: string; lines: string[] } | undefined;
|
||||
let fence: string | undefined;
|
||||
|
||||
for (const line of note.text.split('\n')) {
|
||||
const fenceMark = /^\s{0,3}(```+|~~~+)/.exec(line);
|
||||
if (fenceMark) {
|
||||
if (!fence) fence = fenceMark[1]![0];
|
||||
else if (fenceMark[1]!.startsWith(fence)) fence = undefined;
|
||||
}
|
||||
const heading = fence ? null : /^##\s+(.*\S)\s*$/.exec(line);
|
||||
if (heading) {
|
||||
if (current) sections.push(toSection(current));
|
||||
current = { heading: heading[1]!, lines: [] };
|
||||
continue;
|
||||
}
|
||||
if (current) current.lines.push(line);
|
||||
}
|
||||
if (current) sections.push(toSection(current));
|
||||
return sections;
|
||||
}
|
||||
|
||||
function toSection(raw: { heading: string; lines: string[] }): NoteSection {
|
||||
const subject = subjectFromHeading(raw.heading);
|
||||
return { heading: raw.heading, ...(subject ? { subject } : {}), text: raw.lines.join('\n').trim() };
|
||||
}
|
||||
|
||||
/**
|
||||
* The subject a lesson heading names.
|
||||
*
|
||||
* Lenient on purpose: the page writes `1. Deutsch — 08:00–08:45 · Meier`, but
|
||||
* a heading typed by hand is just `Deutsch`, and both have to work. Leading
|
||||
* period numbers and clock times are stripped, then the subject is whatever
|
||||
* comes before the first separator.
|
||||
*/
|
||||
export function subjectFromHeading(heading: string): string | undefined {
|
||||
let value = heading.trim();
|
||||
value = value.replace(/^\d{1,2}\s*[.)]\s*/, '');
|
||||
value = value.replace(/^(\d{1,2}:\d{2}\s*[–—-]\s*\d{1,2}:\d{2}|\d{1,2}:\d{2})\s*[–—·|-]?\s*/, '');
|
||||
value = value.split(/\s[–—·|]\s|\s{2,}|\(/)[0]!.trim();
|
||||
value = value.replace(/[:,;]+$/, '').trim();
|
||||
// A heading that is only a time, a number or punctuation names no subject,
|
||||
// and guessing one would file the lesson under nonsense.
|
||||
if (!value || !/[\p{L}]/u.test(value) || value.length > 60) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Where a school day's note lives: `2026/2026-09-15.md`. */
|
||||
export function dayNotePath(date: string): string {
|
||||
return `${date.slice(0, 4)}/${date}.md`;
|
||||
}
|
||||
|
||||
// --- matching ------------------------------------------------------------
|
||||
|
||||
/** Everything about a note that search should look at, as one string. */
|
||||
export function noteSearchText(note: NoteDoc): string {
|
||||
return [note.title, note.subject, note.tags.join(' '), note.text].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Every subject a note covers: its own, plus one per lesson heading.
|
||||
*
|
||||
* A day note has no subject of its own and covers five or six, so asking only
|
||||
* the frontmatter would make "my Deutsch notes" return nothing at all for
|
||||
* anyone who writes a note per day.
|
||||
*/
|
||||
export function noteSubjects(note: NoteDoc): string[] {
|
||||
const subjects = note.subject ? [note.subject] : [];
|
||||
for (const section of noteSections(note)) if (section.subject) subjects.push(section.subject);
|
||||
return [...new Set(subjects)];
|
||||
}
|
||||
|
||||
/** Filters a list the way `list_notes` does. Pure, and shared with the CLI. */
|
||||
export function filterNotes(
|
||||
notes: NoteDoc[],
|
||||
filter: { subject?: string; since?: string; until?: string; courseId?: string },
|
||||
): NoteDoc[] {
|
||||
const subject = filter.subject?.trim().toLowerCase();
|
||||
return notes.filter((note) => {
|
||||
if (subject && !noteSubjects(note).some((name) => name.toLowerCase().includes(subject))) return false;
|
||||
if (filter.courseId && note.courseId !== filter.courseId) return false;
|
||||
// A note with no date cannot be excluded by a date window without
|
||||
// silently hiding it; undated notes always pass.
|
||||
if (filter.since && note.date && note.date < filter.since) return false;
|
||||
if (filter.until && note.date && note.date > filter.until) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// --- helpers -------------------------------------------------------------
|
||||
|
||||
function isNoteFile(name: string): boolean {
|
||||
const lower = name.toLowerCase();
|
||||
return NOTE_EXTENSIONS.some((extension) => lower.endsWith(extension));
|
||||
}
|
||||
|
||||
/**
|
||||
* A caller's path in the one form the rest of this module uses.
|
||||
*
|
||||
* Leading slashes and backslashes are accepted and normalised because people
|
||||
* paste `/Deutsch/…` from a listing; traversal is not — `resolveWithin` refuses
|
||||
* it, and this must not quietly make it look legal first.
|
||||
*/
|
||||
export function normalizeRelative(path: string): string {
|
||||
return path.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/').trim();
|
||||
}
|
||||
|
||||
function byNewest(a: NoteDoc, b: NoteDoc): number {
|
||||
// Undated notes sort last: they are usually imports that never carried a
|
||||
// date, and they should not head a list of "the last few lessons".
|
||||
if (a.date && b.date && a.date !== b.date) return b.date.localeCompare(a.date);
|
||||
if (a.date && !b.date) return -1;
|
||||
if (!a.date && b.date) return 1;
|
||||
return b.modifiedAt.localeCompare(a.modifiedAt) || a.path.localeCompare(b.path);
|
||||
}
|
||||
|
||||
function pick<K extends string>(key: K, value: string | undefined): Partial<Record<K, string>> {
|
||||
return value ? ({ [key]: value } as Record<K, string>) : {};
|
||||
}
|
||||
|
||||
function headingTitle(body: string): string | undefined {
|
||||
const match = /^\s*#\s+(.+)$/m.exec(body);
|
||||
return match?.[1]?.trim();
|
||||
}
|
||||
|
||||
function titleFromFileName(fileName: string): string {
|
||||
const withoutExtension = fileName.replace(/\.(md|markdown|txt)$/i, '');
|
||||
return withoutExtension.replace(/^\d{4}-\d{2}-\d{2}[ _-]*/, '').trim() || withoutExtension;
|
||||
}
|
||||
|
||||
function dateFromFileName(fileName: string): string | undefined {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(fileName);
|
||||
return match ? `${match[1]}-${match[2]}-${match[3]}` : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first folder is the subject, by the layout `notePathFor` writes.
|
||||
*
|
||||
* Except a year: notes filed under `2026/` are filed by date, and calling the
|
||||
* year a subject would put every lesson of a school year under one.
|
||||
*/
|
||||
function subjectFromPath(relative: string): string | undefined {
|
||||
const parts = relative.split('/');
|
||||
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. */
|
||||
function normalizeDate(value: string): string | undefined {
|
||||
const german = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(value);
|
||||
if (german) return `${german[3]}-${german[2]!.padStart(2, '0')}-${german[1]!.padStart(2, '0')}`;
|
||||
const iso = /^(\d{4}-\d{2}-\d{2})/.exec(value);
|
||||
return iso ? iso[1] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `- item` lines directly under a `key:` with no inline value.
|
||||
*
|
||||
* Stops at the first line that is not one, so a list never swallows the key
|
||||
* after it.
|
||||
*/
|
||||
function blockList(lines: string[], from: number): string[] {
|
||||
const items: string[] = [];
|
||||
for (let i = from + 1; i < lines.length; i++) {
|
||||
const item = /^[ \t]+-\s+(.*)$/.exec(lines[i]!);
|
||||
if (!item) break;
|
||||
const value = unquote(item[1]!.trim());
|
||||
if (value) items.push(value);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function parseList(value: string): string[] {
|
||||
const inner = /^\[(.*)\]$/.exec(value)?.[1] ?? value;
|
||||
return inner
|
||||
.split(',')
|
||||
.map((entry) => unquote(entry.trim()))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function unquote(value: string): string {
|
||||
const match = /^(['"])(.*)\1$/.exec(value);
|
||||
return match ? match[2]! : value;
|
||||
}
|
||||
|
||||
/** Quotes only when the value would otherwise change meaning on the way back in. */
|
||||
function quote(value: string): string {
|
||||
const clean = value.replace(/[\r\n]+/g, ' ').trim();
|
||||
return /^[\w äöüÄÖÜß.,/()+-]+$/.test(clean) && !/^\[/.test(clean) ? clean : JSON.stringify(clean);
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export function safeComponent(raw: string, fallback = 'untitled'): string {
|
||||
* same card, which the API permits.
|
||||
*/
|
||||
export function mirrorPath(at: Breadcrumb, fileName: string, fileId: string): string {
|
||||
const parts = [at.courseTitle, at.containerTitle, at.cardTitle]
|
||||
const parts = [at.courseTitle, at.containerTitle, ...(at.folders ?? []), at.cardTitle]
|
||||
.filter((part): part is string => Boolean(part && part.trim()))
|
||||
.map((part) => safeComponent(part));
|
||||
|
||||
|
||||
222
src/core/session-token.ts
Normal file
222
src/core/session-token.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import type { Config } from '../config.ts';
|
||||
import { SchulcloudApiError, type SchulcloudClient } from './client.ts';
|
||||
|
||||
/**
|
||||
* The Schulcloud session token, replaceable while the server runs.
|
||||
*
|
||||
* A token lives 30 days at most, and a new one can only come from a browser:
|
||||
* the login is federated single sign-on, so this server cannot mint one
|
||||
* (docs/AUTH.md). What it can do is take a fresh one without a restart — check
|
||||
* it against the instance, swap it into the config every request reads it
|
||||
* from, and keep it in a state file, so a later restart does not fall back to
|
||||
* the older token still sitting in `.env`.
|
||||
*
|
||||
* The token is a credential with read access to the whole account. It goes
|
||||
* into the state file and nowhere else: not into logs, errors or responses.
|
||||
*/
|
||||
|
||||
/** The claims this server reads. Decoded, never verified — Schulcloud does that. */
|
||||
export interface TokenClaims {
|
||||
userId?: string;
|
||||
/** Seconds since the epoch. */
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
export type TokenSource = 'environment' | 'state file' | 'replaced at runtime';
|
||||
|
||||
export interface TokenStatus {
|
||||
expiresAt: string | undefined;
|
||||
/** Whole days until expiry; negative once expired. */
|
||||
daysLeft: number | undefined;
|
||||
source: TokenSource;
|
||||
/** Whether a replacement survives a restart, which needs STATE_DIR. */
|
||||
persistent: boolean;
|
||||
}
|
||||
|
||||
export type TokenProblem = 'malformed' | 'expired' | 'rejected' | 'other_account';
|
||||
|
||||
/** A replacement that was refused, with a message saying what to do instead. */
|
||||
export class TokenRejected extends Error {
|
||||
readonly problem: TokenProblem;
|
||||
|
||||
constructor(problem: TokenProblem, message: string) {
|
||||
super(message);
|
||||
this.name = 'TokenRejected';
|
||||
this.problem = problem;
|
||||
}
|
||||
}
|
||||
|
||||
const STATE_FILE = 'schulcloud-jwt';
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* The token inside whatever was pasted.
|
||||
*
|
||||
* DevTools copies the bare value, but a cookie line (`jwt=…; Path=/`), quotes
|
||||
* and a trailing newline all happen on the way from a browser to a terminal,
|
||||
* and none of them is worth a refused replacement.
|
||||
*/
|
||||
export function normalizeToken(input: string): string {
|
||||
const unquote = (value: string) => value.trim().replace(/^(["'])(.*)\1$/s, '$2').trim();
|
||||
return unquote(
|
||||
unquote(input)
|
||||
.replace(/^jwt\s*=\s*/i, '')
|
||||
.replace(/;.*$/s, ''),
|
||||
);
|
||||
}
|
||||
|
||||
export function decodeClaims(token: string): TokenClaims | undefined {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3 || parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))) return undefined;
|
||||
try {
|
||||
const payload: unknown = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString('utf8'));
|
||||
if (!payload || typeof payload !== 'object') return undefined;
|
||||
const { userId, exp } = payload as Record<string, unknown>;
|
||||
return {
|
||||
userId: typeof userId === 'string' ? userId : undefined,
|
||||
exp: typeof exp === 'number' ? exp : undefined,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionToken {
|
||||
private readonly config: Config;
|
||||
private readonly client: Pick<SchulcloudClient, 'meAs'>;
|
||||
private readonly stateFile: string | undefined;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private source: TokenSource = 'environment';
|
||||
/** One replacement at a time, so two pastes cannot interleave a swap and a write. */
|
||||
private queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(config: Config, client: Pick<SchulcloudClient, 'meAs'>, stateDir?: string) {
|
||||
this.config = config;
|
||||
this.client = client;
|
||||
this.stateFile = stateDir ? join(stateDir, STATE_FILE) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the token to start with: the saved one when it is the newer of the
|
||||
* two, since that is what a runtime replacement leaves behind — but never one
|
||||
* for a different account than `TSC_JWT_COOKIE`, because changing that
|
||||
* variable is how accounts are switched.
|
||||
*/
|
||||
async load(log: (message: string) => void = (message) => console.error(message)): Promise<void> {
|
||||
if (!this.stateFile) return;
|
||||
let saved: string;
|
||||
try {
|
||||
saved = normalizeToken(await readFile(this.stateFile, 'utf8'));
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ENOENT') log(`[schulcloud-mcp] session token: could not read the saved one (${code}); using TSC_JWT_COOKIE`);
|
||||
return;
|
||||
}
|
||||
if (!saved || saved === this.config.jwt) return;
|
||||
|
||||
const fromState = decodeClaims(saved);
|
||||
const fromEnvironment = decodeClaims(this.config.jwt);
|
||||
if (!fromState) {
|
||||
log('[schulcloud-mcp] session token: the saved one is unreadable; using TSC_JWT_COOKIE');
|
||||
return;
|
||||
}
|
||||
if (fromEnvironment?.userId && fromState.userId !== fromEnvironment.userId) {
|
||||
log('[schulcloud-mcp] session token: the saved one belongs to another account than TSC_JWT_COOKIE; using TSC_JWT_COOKIE');
|
||||
return;
|
||||
}
|
||||
if ((fromState.exp ?? 0) > (fromEnvironment?.exp ?? 0)) {
|
||||
this.config.jwt = saved;
|
||||
this.source = 'state file';
|
||||
log('[schulcloud-mcp] session token: using the one replaced at runtime, which is newer than TSC_JWT_COOKIE');
|
||||
}
|
||||
}
|
||||
|
||||
status(): TokenStatus {
|
||||
const exp = decodeClaims(this.config.jwt)?.exp;
|
||||
return {
|
||||
expiresAt: exp === undefined ? undefined : new Date(exp * 1000).toISOString(),
|
||||
daysLeft: exp === undefined ? undefined : Math.floor((exp * 1000 - Date.now()) / DAY_MS),
|
||||
source: this.source,
|
||||
persistent: this.stateFile !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Called after every successful replacement — the keepalive restarts on it. */
|
||||
onReplaced(listener: () => void): void {
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
|
||||
replace(input: string): Promise<{ changed: boolean; persisted: boolean; status: TokenStatus }> {
|
||||
const run = this.queue.then(() => this.swap(input));
|
||||
this.queue = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
private async swap(input: string): Promise<{ changed: boolean; persisted: boolean; status: TokenStatus }> {
|
||||
const token = normalizeToken(input);
|
||||
const claims = decodeClaims(token);
|
||||
if (!claims) {
|
||||
throw new TokenRejected(
|
||||
'malformed',
|
||||
'That is not a jwt cookie value: it should be three parts separated by dots, starting with "eyJ". ' +
|
||||
'Copy the Value column of the cookie named "jwt".',
|
||||
);
|
||||
}
|
||||
if (claims.exp !== undefined && claims.exp * 1000 <= Date.now()) {
|
||||
throw new TokenRejected(
|
||||
'expired',
|
||||
`That token expired on ${new Date(claims.exp * 1000).toISOString().slice(0, 10)}. Log in again and copy the new cookie.`,
|
||||
);
|
||||
}
|
||||
if (token === this.config.jwt) return { changed: false, persisted: false, status: this.status() };
|
||||
|
||||
let userId: string;
|
||||
try {
|
||||
userId = (await this.client.meAs(token)).user.id;
|
||||
} catch (error) {
|
||||
if (error instanceof SchulcloudApiError && error.isAuthFailure) {
|
||||
throw new TokenRejected(
|
||||
'rejected',
|
||||
'Schulcloud rejected that token (401): its session has already ended. Log in again in a private ' +
|
||||
'window and copy the cookie — then close the window, because left open it logs the token out ' +
|
||||
'about two hours after login.',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const current = decodeClaims(this.config.jwt)?.userId;
|
||||
if (current && userId !== current) {
|
||||
throw new TokenRejected(
|
||||
'other_account',
|
||||
'That token belongs to a different Schulcloud account than the one this server reads. ' +
|
||||
'To switch accounts, change TSC_JWT_COOKIE and restart the server.',
|
||||
);
|
||||
}
|
||||
|
||||
this.config.jwt = token;
|
||||
this.source = 'replaced at runtime';
|
||||
const persisted = await this.persist(token);
|
||||
for (const listener of this.listeners) listener();
|
||||
return { changed: true, persisted, status: this.status() };
|
||||
}
|
||||
|
||||
/** Written beside itself and renamed into place, so a crash cannot leave half a token. */
|
||||
private async persist(token: string): Promise<boolean> {
|
||||
if (!this.stateFile) return false;
|
||||
try {
|
||||
await mkdir(dirname(this.stateFile), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${this.stateFile}.${process.pid}.tmp`;
|
||||
await writeFile(temporary, `${token}\n`, { mode: 0o600 });
|
||||
await rename(temporary, this.stateFile);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[schulcloud-mcp] session token: replaced, but not saved (${(error as NodeJS.ErrnoException).code ?? 'error'}); ` +
|
||||
'a restart will fall back to TSC_JWT_COOKIE',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,8 +57,24 @@ export function htmlToText(html: string | undefined | null): string {
|
||||
if (!html) return '';
|
||||
return decodeEntities(
|
||||
html
|
||||
// A newline in HTML source is just whitespace; only tags make lines.
|
||||
// Flattening first is what stops `<br>` followed by a newline — the
|
||||
// shape every server-side template produces — from reading as a
|
||||
// paragraph break, and it takes the template's indentation with it.
|
||||
.replace(/\s*\n\s*/g, ' ')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|h[1-6]|li|tr)>/gi, '\n')
|
||||
// A cell whose text is wrapped in its own <p> — which is what the
|
||||
// editor produces — would otherwise break its row in half.
|
||||
.replace(/<(td|th)([^>]*)>\s*<p[^>]*>/gi, '<$1$2>')
|
||||
.replace(/<\/p>\s*<\/(td|th)>/gi, '</$1>')
|
||||
// Cells before rows: a table flattened without cell separators runs
|
||||
// its columns together, which is how a two-column worksheet grid came
|
||||
// out as a meaningless list of fragments.
|
||||
.replace(/<\/(td|th)>/gi, ' | ')
|
||||
// Paragraphs and headings read as paragraphs; list items and table
|
||||
// rows are single lines.
|
||||
.replace(/<\/(p|h[1-6])>/gi, '\n\n')
|
||||
.replace(/<\/(div|li|tr)>/gi, '\n')
|
||||
.replace(/<li[^>]*>/gi, '- ')
|
||||
// Keep the href when the anchor text does not already contain it.
|
||||
.replace(/<a\b[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gis, (_, href: string, label: string) => {
|
||||
@@ -68,7 +84,14 @@ export function htmlToText(html: string | undefined | null): string {
|
||||
})
|
||||
.replace(/<[^>]+>/g, ''),
|
||||
)
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
// Source HTML is pretty-printed, so nearly every line arrives with the
|
||||
// template's indentation still attached. Collapsing runs of spaces and
|
||||
// trimming each line is what HTML rendering would have done anyway, and
|
||||
// without it a submission reads as prose adrift in whitespace.
|
||||
.replace(/[^\S\n]+/g, ' ')
|
||||
.split('\n')
|
||||
.map((line) => line.trim().replace(/\s*\|\s*$/, ''))
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
63
src/core/totp.ts
Normal file
63
src/core/totp.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* RFC 6238 one-time codes, for the WebUntis mobile API.
|
||||
*
|
||||
* WebUntis authenticates the mobile app not with a password but with a static
|
||||
* base32 key (the one behind the QR code in Profil → Freigaben) from which each
|
||||
* request derives a fresh code. That makes the credential usable by an
|
||||
* always-on server without a session to hold open — see core/untis.ts.
|
||||
*
|
||||
* Implemented here rather than pulled in: it is HMAC-SHA1 plus a truncation,
|
||||
* `node:crypto` has the hard part, and a dependency that handles a credential
|
||||
* is a dependency worth not having.
|
||||
*/
|
||||
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
/**
|
||||
* Decodes base32 (RFC 4648) as written on a QR code: padding and lowercase are
|
||||
* accepted, anything outside the alphabet is an error rather than a silently
|
||||
* wrong key.
|
||||
*/
|
||||
export function base32Decode(value: string): Buffer {
|
||||
const clean = value.replace(/[=\s]/g, '').toUpperCase();
|
||||
if (clean.length === 0) throw new Error('base32 value is empty');
|
||||
const bytes: number[] = [];
|
||||
let buffer = 0;
|
||||
let bits = 0;
|
||||
for (const char of clean) {
|
||||
const index = BASE32_ALPHABET.indexOf(char);
|
||||
if (index === -1) throw new Error('base32 value contains a character outside A-Z and 2-7');
|
||||
buffer = (buffer << 5) | index;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bytes.push((buffer >>> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* The current 6-digit code for a base32 secret, as a **zero-padded string**.
|
||||
*
|
||||
* A string on purpose: one code in ten starts with a zero, and sending it as a
|
||||
* JSON number drops that digit. WebUntis accepts either shape, so the string is
|
||||
* the one that is always right.
|
||||
*/
|
||||
export function totp(secret: string, at: number = Date.now(), stepSeconds = 30, digits = 6): string {
|
||||
const counter = Math.floor(at / 1000 / stepSeconds);
|
||||
const message = Buffer.alloc(8);
|
||||
// Counter is 64-bit big-endian; Node has no writeUInt64BE.
|
||||
message.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
|
||||
message.writeUInt32BE(counter >>> 0, 4);
|
||||
|
||||
const digest = createHmac('sha1', base32Decode(secret)).update(message).digest();
|
||||
// RFC 6238 dynamic truncation: the low nibble of the last byte picks the
|
||||
// 4-byte window, whose top bit is masked off to keep it positive.
|
||||
const offset = digest[digest.length - 1]! & 0x0f;
|
||||
const binary =
|
||||
((digest[offset]! & 0x7f) << 24) | (digest[offset + 1]! << 16) | (digest[offset + 2]! << 8) | digest[offset + 3]!;
|
||||
return String(binary % 10 ** digits).padStart(digits, '0');
|
||||
}
|
||||
@@ -71,6 +71,39 @@ export interface TaskContent {
|
||||
status: TaskStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* A topic's task as `GET /lessons/{id}/tasks` returns it.
|
||||
*
|
||||
* Deliberately not a `TaskContent`: the response carries no `id` and no
|
||||
* `status` — `LessonLinkedTaskResponse` simply has no id property. Treating it
|
||||
* as a TaskContent made `task.id` undefined at runtime while the type claimed
|
||||
* otherwise, which is how topic-attached tasks went missing in silence.
|
||||
* `core/lesson-page.ts` recovers the ids.
|
||||
*/
|
||||
export interface LessonLinkedTask {
|
||||
/** Absent from the API; filled in from the topic page when it can be. */
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
availableDate?: string;
|
||||
dueDate?: string | null;
|
||||
courseId?: string;
|
||||
courseName?: string;
|
||||
lessonName?: string;
|
||||
private?: boolean;
|
||||
submissionIds?: string[];
|
||||
finishedIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A task as the tools render it, from whichever route found it.
|
||||
*
|
||||
* The task lists and course pages carry a `status`; the topic projection does
|
||||
* not, and carries no id until one is scraped. Every `TaskContent` satisfies
|
||||
* this, so list-derived tasks keep their full detail.
|
||||
*/
|
||||
export type ResolvedTask = LessonLinkedTask & { status?: TaskStatus };
|
||||
|
||||
export interface LessonMetaContent {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -206,6 +239,86 @@ export type FileParentType =
|
||||
| 'boardnodes'
|
||||
| 'externaltools';
|
||||
|
||||
/**
|
||||
* A room ("Raum") — the newer collaboration space, separate from courses.
|
||||
*
|
||||
* The naming is a trap worth knowing: the sidebar's *Kurse* entry points at
|
||||
* `/rooms/courses-overview` and shows courses, while *Räume* points at `/rooms`
|
||||
* and shows these. A url containing `/rooms` says nothing about which one it is.
|
||||
*
|
||||
* Unlike a course, a room holds only boards — no lessons, no tasks.
|
||||
*/
|
||||
export interface RoomItem {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
schoolId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* What this account may do here.
|
||||
*
|
||||
* An object keyed by operation, not a list of granted ones: every operation
|
||||
* is present and false means denied. Typing it as `string[]` type-checked
|
||||
* fine and threw `.some is not a function` the moment anything read it.
|
||||
*/
|
||||
allowedOperations?: Record<string, boolean>;
|
||||
isLocked?: boolean;
|
||||
totalMembers?: number;
|
||||
}
|
||||
|
||||
export interface RoomDetails extends Omit<RoomItem, 'isLocked' | 'totalMembers'> {
|
||||
features?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A board inside a room.
|
||||
*
|
||||
* Carries `isVisible`, which the course-page projection does not — so unlike a
|
||||
* course board, a room's draft boards can be told apart before trying to open
|
||||
* one and getting a 403.
|
||||
*/
|
||||
export interface RoomBoardItem {
|
||||
id: string;
|
||||
title: string;
|
||||
layout?: string;
|
||||
isVisible?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
allowedOperations?: string[];
|
||||
}
|
||||
|
||||
export interface RoomMember {
|
||||
userId: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
/** roomowner | roomadmin | roomeditor | roomviewer */
|
||||
roomRoleName?: string;
|
||||
schoolRoleNames?: string[];
|
||||
schoolName?: string;
|
||||
}
|
||||
|
||||
/** Someone who has asked to join a room and is waiting for an admin. */
|
||||
export interface RoomApplicant {
|
||||
userId?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
schoolName?: string;
|
||||
requestedAt?: string;
|
||||
}
|
||||
|
||||
/** A shareable link into a room. Visible only to accounts that may manage them. */
|
||||
export interface RoomInvitationLink {
|
||||
id: string;
|
||||
title?: string;
|
||||
activeUntil?: string;
|
||||
isOnlyForTeachers?: boolean;
|
||||
restrictedToCreatorSchool?: boolean;
|
||||
requiresConfirmation?: boolean;
|
||||
}
|
||||
|
||||
export const FILE_PARENT_TYPES: FileParentType[] = [
|
||||
'users',
|
||||
'schools',
|
||||
@@ -261,3 +374,84 @@ export interface NewsResponse {
|
||||
creator?: { id: string; firstName?: string; lastName?: string };
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A course as the legacy `/api/v1/courses` service returns it.
|
||||
*
|
||||
* The v3 projection (`CourseMetadataResponse`) carries only id, title, colour
|
||||
* and dates — no description, no teachers, no members, no timetable. All of
|
||||
* that still exists, and `/api/v1/courses` is one of exactly three legacy
|
||||
* routes the deployment's own ingress table still publishes
|
||||
* (`dof_app_deploy/ansible/group_vars/all/x_ingress.yml`: courses, users,
|
||||
* classes), so this is production surface rather than a leftover.
|
||||
*/
|
||||
export interface LegacyCourse {
|
||||
_id?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
startDate?: string;
|
||||
untilDate?: string;
|
||||
isArchived?: boolean;
|
||||
teacherIds?: string[];
|
||||
substitutionIds?: string[];
|
||||
userIds?: string[];
|
||||
classIds?: string[];
|
||||
/** The weekly timetable: one entry per recurring slot. */
|
||||
times?: CourseTime[];
|
||||
}
|
||||
|
||||
/** One recurring slot of a course's weekly timetable. */
|
||||
export interface CourseTime {
|
||||
/** 0 = Monday, as the legacy client renders it. */
|
||||
weekday?: number;
|
||||
/** Milliseconds since midnight. */
|
||||
startTime?: number;
|
||||
/** Milliseconds. */
|
||||
duration?: number;
|
||||
room?: string;
|
||||
}
|
||||
|
||||
/** A user as `/api/v1/users/{id}` returns it — the only way to turn an id into a name. */
|
||||
export interface LegacyUser {
|
||||
_id?: string;
|
||||
id?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
fullName?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
/** A class ("Klasse") from `/api/v3/groups/class`. */
|
||||
export interface ClassItem {
|
||||
id: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
teacherNames?: string[];
|
||||
studentCount?: number;
|
||||
isUpgradable?: boolean;
|
||||
}
|
||||
|
||||
/** A group from `/api/v3/groups` — room membership groups, classes, courses. */
|
||||
export interface GroupItem {
|
||||
id: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
organizationId?: string;
|
||||
users?: { id: string; firstName?: string; lastName?: string; role?: string }[];
|
||||
}
|
||||
|
||||
/** `GET /api/v3/file/stats/{parentType}/{parentId}`. */
|
||||
export interface ParentFileStats {
|
||||
fileCount: number;
|
||||
totalSizeInBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Widths the preview endpoint accepts.
|
||||
*
|
||||
* An enum rather than a free number — `width=1600` is rejected as a validation
|
||||
* error that names the value but not the permitted set.
|
||||
*/
|
||||
export type PreviewWidth = 50 | 150 | 500;
|
||||
|
||||
178
src/core/untis-history.ts
Normal file
178
src/core/untis-history.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { addDays, daysBetween } from './dates.ts';
|
||||
import type { UntisClient, UntisLesson } from './untis.ts';
|
||||
|
||||
/**
|
||||
* The class register, read backwards: what every past lesson actually covered.
|
||||
*
|
||||
* `untis_lesson_topics` answers this one series at a time, from a period id the
|
||||
* caller already has. That is the wrong shape for two of the questions this
|
||||
* exists for — "what have we done in Deutsch this term" and "where does the
|
||||
* material about X come from" — because neither starts from a period id, and
|
||||
* the second needs the text in the search index rather than in a tool call.
|
||||
*
|
||||
* So this walks a date range and merges the two halves the API keeps apart:
|
||||
*
|
||||
* - `getTimetable2017` gives the periods, and with them what a teacher wrote on
|
||||
* one (`text.info` is where this school announces its tests), the homework and
|
||||
* any exam.
|
||||
* - `getLessonTopic2017` gives the `Unterrichtsinhalt` — but only per *series*,
|
||||
* as "the previous topics of this lesson". One call per series therefore
|
||||
* covers all of its past lessons at once, which is why this asks per series
|
||||
* and not per period: a term is a few dozen calls, not a few hundred.
|
||||
*
|
||||
* Requests are sequential on purpose. Every WebUntis call carries its own
|
||||
* one-time code, and the index is built by a background crawl that nobody is
|
||||
* waiting on, so there is nothing to buy by running them in parallel.
|
||||
*/
|
||||
|
||||
/** The longest range one `getTimetable2017` call is asked for. */
|
||||
const CHUNK_DAYS = 90;
|
||||
|
||||
export interface LessonLogEntry {
|
||||
periodId: number;
|
||||
/** The series id: every Tuesday-second-period German lesson shares it. */
|
||||
lessonId: number;
|
||||
date: string;
|
||||
start: string;
|
||||
end: string;
|
||||
subject?: string;
|
||||
subjectLong?: string;
|
||||
teachers: string[];
|
||||
/** The class register's "Unterrichtsinhalt" for this period, when the teacher filled it in. */
|
||||
topic?: string;
|
||||
/** The free-text fields on the period. `info` is where announced tests live. */
|
||||
notes: { lesson?: string; substitution?: string; info?: string };
|
||||
homework: { text: string; due: string }[];
|
||||
exam?: string;
|
||||
}
|
||||
|
||||
export interface LessonLog {
|
||||
from: string;
|
||||
to: string;
|
||||
entries: LessonLogEntry[];
|
||||
/** Series whose topics could not be read, so a gap is visible rather than silent. */
|
||||
failures: { lessonId: number; reason: string }[];
|
||||
/** Periods seen in the range, including the ones that carried nothing. */
|
||||
periodsSeen: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the log for `[from, to]`.
|
||||
*
|
||||
* Only periods that carry something are returned: a lesson with neither a
|
||||
* topic, nor a note, nor homework, nor an exam has nothing to say, and
|
||||
* indexing it would bury the ones that do under a term of empty rows.
|
||||
*/
|
||||
export async function collectLessonLog(
|
||||
untis: UntisClient,
|
||||
options: { from: string; to: string; subject?: string },
|
||||
): Promise<LessonLog> {
|
||||
const lessons: UntisLesson[] = [];
|
||||
for (const [chunkFrom, chunkTo] of chunkRange(options.from, options.to)) {
|
||||
const table = await untis.timetable(chunkFrom, chunkTo);
|
||||
for (const day of table.days) lessons.push(...day.lessons);
|
||||
}
|
||||
|
||||
// A cancelled period taught nothing, and its replacement beside it carries
|
||||
// whatever actually happened.
|
||||
const held = lessons.filter((lesson) => !lesson.cancelled).filter((lesson) => matchesSubject(lesson, options.subject));
|
||||
|
||||
const topics = new Map<number, string>();
|
||||
const failures: { lessonId: number; reason: string }[] = [];
|
||||
for (const [lessonId, periodId] of latestPeriodPerSeries(held)) {
|
||||
try {
|
||||
for (const topic of await untis.lessonTopics(periodId)) topics.set(topic.periodId, topic.text);
|
||||
} catch (error) {
|
||||
// One series the register refuses must not cost the rest of the term.
|
||||
failures.push({ lessonId, reason: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
const entries = held
|
||||
.map((lesson): LessonLogEntry => {
|
||||
const subject = lesson.subjects[0];
|
||||
return {
|
||||
periodId: lesson.periodId,
|
||||
lessonId: lesson.lessonId,
|
||||
date: lesson.date,
|
||||
start: lesson.start,
|
||||
end: lesson.end,
|
||||
...(subject?.name ? { subject: subject.name } : {}),
|
||||
...(subject?.longName ? { subjectLong: subject.longName } : {}),
|
||||
teachers: lesson.teachers.map((teacher) => teacher.longName || teacher.name),
|
||||
...(topics.get(lesson.periodId) ? { topic: topics.get(lesson.periodId) } : {}),
|
||||
notes: lesson.notes,
|
||||
homework: lesson.homework.map((item) => ({ text: item.text, due: item.due })),
|
||||
...(lesson.exam ? { exam: lesson.exam } : {}),
|
||||
};
|
||||
})
|
||||
.filter(hasContent)
|
||||
.sort((a, b) => b.date.localeCompare(a.date) || b.start.localeCompare(a.start));
|
||||
|
||||
return { from: options.from, to: options.to, entries, failures, periodsSeen: held.length };
|
||||
}
|
||||
|
||||
/** True when the entry records anything worth keeping. */
|
||||
export function hasContent(entry: LessonLogEntry): boolean {
|
||||
return Boolean(
|
||||
entry.topic ||
|
||||
entry.notes.lesson ||
|
||||
entry.notes.info ||
|
||||
entry.notes.substitution ||
|
||||
entry.exam ||
|
||||
entry.homework.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything the entry says, as one string — the body the index gets. */
|
||||
export function lessonLogText(entry: LessonLogEntry): string {
|
||||
return [
|
||||
entry.topic,
|
||||
entry.notes.info,
|
||||
entry.notes.lesson,
|
||||
entry.notes.substitution,
|
||||
entry.exam ? `Prüfung: ${entry.exam}` : undefined,
|
||||
...entry.homework.map((item) => `Hausaufgabe bis ${item.due}: ${item.text}`),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** `LF07` matches the subject `LF07` and the long name `Lernfeld 7`. */
|
||||
function matchesSubject(lesson: UntisLesson, subject: string | undefined): boolean {
|
||||
if (!subject) return true;
|
||||
const wanted = subject.trim().toLowerCase();
|
||||
return lesson.subjects.some(
|
||||
(entry) =>
|
||||
entry.name.toLowerCase().includes(wanted) || (entry.longName ?? '').toLowerCase().includes(wanted),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One period id per series — the latest one.
|
||||
*
|
||||
* `getLessonTopic2017` answers with the topics of the lessons *before* the
|
||||
* period it is given, so asking about the last lesson of a series reaches the
|
||||
* whole of its history and asking about the first reaches none of it.
|
||||
*/
|
||||
function latestPeriodPerSeries(lessons: UntisLesson[]): Map<number, number> {
|
||||
const latest = new Map<number, { periodId: number; at: string }>();
|
||||
for (const lesson of lessons) {
|
||||
const at = `${lesson.date} ${lesson.start}`;
|
||||
const current = latest.get(lesson.lessonId);
|
||||
if (!current || at > current.at) latest.set(lesson.lessonId, { periodId: lesson.periodId, at });
|
||||
}
|
||||
return new Map([...latest].map(([lessonId, entry]) => [lessonId, entry.periodId]));
|
||||
}
|
||||
|
||||
/** Splits a range into windows the timetable call will accept. */
|
||||
export function chunkRange(from: string, to: string): [string, string][] {
|
||||
const chunks: [string, string][] = [];
|
||||
let start = from;
|
||||
while (start <= to) {
|
||||
const end = daysBetween(start, to) > CHUNK_DAYS ? addDays(start, CHUNK_DAYS) : to;
|
||||
chunks.push([start, end]);
|
||||
start = addDays(end, 1);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
555
src/core/untis.ts
Normal file
555
src/core/untis.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* WebUntis: the timetable, its changes, homework and what was taught.
|
||||
*
|
||||
* The school keeps its timetable in WebUntis, not in Schulcloud — Schulcloud's
|
||||
* course `times` are empty here — so "what do I have today, and has anything
|
||||
* been cancelled" is a question only this API can answer. It is the other half
|
||||
* of a school day: Schulcloud holds the material, WebUntis holds the schedule.
|
||||
*
|
||||
* **Read-only, but not by the Schulcloud client's rule.** This is JSON-RPC:
|
||||
* every call is a POST, reads included, so "GET only" cannot be the guarantee.
|
||||
* `READ_METHODS` is: `call` refuses any method outside it. That matters because
|
||||
* the key is the mobile app's credential and can do what the app can — this
|
||||
* account holds `W_OWN_ABSENCE`, so the same key could report the user absent.
|
||||
*
|
||||
* Authentication is a one-time code derived from the base32 key behind the QR
|
||||
* code in WebUntis → Profil → Freigaben (see core/totp.ts). Each request signs
|
||||
* itself, so unlike the Schulcloud session there is nothing to hold open and
|
||||
* nothing to refresh — but the server's clock has to be right, which is what
|
||||
* error -8524 means.
|
||||
*/
|
||||
|
||||
import { compactDate } from './dates.ts';
|
||||
import { totp } from './totp.ts';
|
||||
|
||||
export interface UntisConfig {
|
||||
/** Bare host from the QR dialog's "Url" field, e.g. `ags-erfurt.webuntis.com`. */
|
||||
server: string;
|
||||
/** The school's login name, e.g. `ags-erfurt`. */
|
||||
school: string;
|
||||
user: string;
|
||||
/** The base32 key from the QR dialog. A credential: never log it. */
|
||||
secret: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The methods this client may call. Everything here reads; nothing writes.
|
||||
*
|
||||
* Verified against the live instance — `getClassregEvents2017` and
|
||||
* `getSchoolyears2017` answer "Method not found" and are deliberately absent.
|
||||
*/
|
||||
const READ_METHODS = new Set([
|
||||
'getUserData2017',
|
||||
'getTimetable2017',
|
||||
'getLessonTopic2017',
|
||||
'getHomeWork2017',
|
||||
'getMessagesOfDay2017',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The read-only guarantee for this API, at its single choke point.
|
||||
*
|
||||
* Exported so a test can hold it to it: this is the line that keeps a key which
|
||||
* *can* write from being used to write.
|
||||
*/
|
||||
export function assertReadMethod(method: string): void {
|
||||
if (!READ_METHODS.has(method)) {
|
||||
throw new Error(`Refusing to call WebUntis method ${method}: not in the read-only allowlist.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The client version the mobile API expects as `?v=`.
|
||||
*
|
||||
* Not optional: `jsonrpc_intern.do` reads the parameter without checking it,
|
||||
* so omitting it fails with a Java NullPointerException reported as -8998.
|
||||
*/
|
||||
const API_VERSION = 'i3.2';
|
||||
|
||||
/** How long master data (subjects, teachers, rooms, holidays) is reused. */
|
||||
const MASTER_DATA_TTL_MS = 6 * 60 * 60_000;
|
||||
|
||||
/** A JSON-RPC error from WebUntis. They arrive with HTTP 200 and an `error` body. */
|
||||
export class UntisApiError extends Error {
|
||||
readonly code: number;
|
||||
readonly method: string;
|
||||
|
||||
constructor(code: number, method: string, message: string) {
|
||||
super(`WebUntis ${method} failed (${code}): ${message}`);
|
||||
this.name = 'UntisApiError';
|
||||
this.code = code;
|
||||
this.method = method;
|
||||
}
|
||||
|
||||
/** -8504: the key or user name is wrong, or the key has been regenerated. */
|
||||
get isAuthFailure(): boolean {
|
||||
return this.code === -8504;
|
||||
}
|
||||
|
||||
/** -8524: our clock is too far from the school server's. */
|
||||
get isClockSkew(): boolean {
|
||||
return this.code === -8524;
|
||||
}
|
||||
}
|
||||
|
||||
// --- what callers get ----------------------------------------------------
|
||||
|
||||
/** A subject, teacher, room or class: a short code plus, where known, a full name. */
|
||||
export interface UntisName {
|
||||
name: string;
|
||||
longName?: string;
|
||||
}
|
||||
|
||||
export interface UntisHomework {
|
||||
id: number;
|
||||
lessonId: number;
|
||||
/** When it was set, `YYYY-MM-DD`. */
|
||||
assigned: string;
|
||||
/** When it is due, `YYYY-MM-DD`. */
|
||||
due: string;
|
||||
text: string;
|
||||
remark?: string;
|
||||
completed: boolean;
|
||||
subject?: UntisName;
|
||||
attachments: number;
|
||||
}
|
||||
|
||||
export interface UntisLesson {
|
||||
/** The period id, which `getLessonTopic2017` takes. */
|
||||
periodId: number;
|
||||
/** The lesson (series) id: the same weekly slot shares it. */
|
||||
lessonId: number;
|
||||
date: string;
|
||||
/** `HH:MM` in the school's local time. */
|
||||
start: string;
|
||||
end: string;
|
||||
/** Raw status words, e.g. `REGULAR`, `CANCELLED`, `IRREGULAR`. */
|
||||
statuses: string[];
|
||||
cancelled: boolean;
|
||||
/** A substitution, a moved lesson or anything else Untis calls irregular. */
|
||||
changed: boolean;
|
||||
subjects: UntisName[];
|
||||
teachers: UntisName[];
|
||||
rooms: UntisName[];
|
||||
classes: UntisName[];
|
||||
/** What each kind of element replaced, when Untis says so (its `orgId`). */
|
||||
replaced: { subjects: UntisName[]; teachers: UntisName[]; rooms: UntisName[] };
|
||||
/** The three free-text fields a teacher can attach to a period. */
|
||||
notes: { lesson?: string; substitution?: string; info?: string };
|
||||
homework: UntisHomework[];
|
||||
/** The exam module's title, when the school uses it. */
|
||||
exam?: string;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export interface UntisHoliday {
|
||||
name: string;
|
||||
longName: string;
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface UntisDay {
|
||||
date: string;
|
||||
lessons: UntisLesson[];
|
||||
/** Holidays and single free days covering this date. */
|
||||
holidays: UntisHoliday[];
|
||||
}
|
||||
|
||||
export interface UntisTimetable {
|
||||
from: string;
|
||||
to: string;
|
||||
/** Every date in the range, including the ones without lessons. */
|
||||
days: UntisDay[];
|
||||
}
|
||||
|
||||
export interface UntisIdentity {
|
||||
displayName: string;
|
||||
elementId: number;
|
||||
elementType: string;
|
||||
schoolName: string;
|
||||
/** Untis' own permission words, e.g. `R_MY_ABSENCES`, `W_OWN_ABSENCE`. */
|
||||
rights: string[];
|
||||
}
|
||||
|
||||
/** One class-register entry: what was taught in a lesson of this series. */
|
||||
export interface UntisTopic {
|
||||
text: string;
|
||||
periodId: number;
|
||||
date: string;
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface UntisMessage {
|
||||
subject: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// --- raw shapes ----------------------------------------------------------
|
||||
|
||||
interface RawElement {
|
||||
type: string;
|
||||
id: number;
|
||||
orgId?: number;
|
||||
}
|
||||
|
||||
interface RawPeriod {
|
||||
id: number;
|
||||
lessonId: number;
|
||||
startDateTime: string;
|
||||
endDateTime: string;
|
||||
text?: { lesson?: string; substitution?: string; info?: string; attachments?: unknown[] };
|
||||
elements?: RawElement[];
|
||||
is?: string[];
|
||||
homeWorks?: RawHomework[];
|
||||
exam?: { name?: string; text?: string } | null;
|
||||
isOnlinePeriod?: boolean;
|
||||
}
|
||||
|
||||
interface RawHomework {
|
||||
id: number;
|
||||
lessonId: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
text: string;
|
||||
remark?: string | null;
|
||||
completed?: boolean;
|
||||
attachments?: unknown[];
|
||||
}
|
||||
|
||||
interface RawNamed {
|
||||
id: number;
|
||||
name: string;
|
||||
longName?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
}
|
||||
|
||||
interface RawMasterData {
|
||||
timeStamp?: number;
|
||||
subjects?: RawNamed[];
|
||||
teachers?: RawNamed[];
|
||||
rooms?: RawNamed[];
|
||||
klassen?: RawNamed[];
|
||||
holidays?: { name: string; longName: string; startDate: string; endDate: string }[];
|
||||
}
|
||||
|
||||
interface RawUserData {
|
||||
userData?: {
|
||||
displayName?: string;
|
||||
elemId?: number;
|
||||
elemType?: string;
|
||||
schoolName?: string;
|
||||
rights?: string[];
|
||||
};
|
||||
masterData?: RawMasterData;
|
||||
}
|
||||
|
||||
interface RawTimetable {
|
||||
timetable?: { periods?: RawPeriod[] };
|
||||
masterData?: RawMasterData;
|
||||
}
|
||||
|
||||
// --- client --------------------------------------------------------------
|
||||
|
||||
export class UntisClient {
|
||||
private readonly config: UntisConfig;
|
||||
private readonly timeoutMs: number;
|
||||
private identityPromise: Promise<UntisIdentity> | undefined;
|
||||
private masterData: { data: RawMasterData; at: number } | undefined;
|
||||
|
||||
constructor(config: UntisConfig, timeoutMs = 30_000) {
|
||||
this.config = config;
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
/** For status lines: where this client is pointed, never how it authenticates. */
|
||||
get origin(): string {
|
||||
return `${this.config.server}/${this.config.school}`;
|
||||
}
|
||||
|
||||
private async call<T>(method: string, params: Record<string, unknown>): Promise<T> {
|
||||
// The allowlist is the read-only guarantee for this API; widening it is a
|
||||
// deliberate act, not something a caller can do by passing a string.
|
||||
assertReadMethod(method);
|
||||
const url =
|
||||
`https://${this.config.server}/WebUntis/jsonrpc_intern.do` +
|
||||
`?m=${encodeURIComponent(method)}&school=${encodeURIComponent(this.config.school)}&v=${API_VERSION}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
// Honest, and accepted: the endpoint does not check for the app's own
|
||||
// user agent.
|
||||
'user-agent': 'schulcloud-mcp',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: 'schulcloud-mcp',
|
||||
jsonrpc: '2.0',
|
||||
method,
|
||||
params: [
|
||||
{
|
||||
...params,
|
||||
// The code is a string: one in ten starts with a zero, which a
|
||||
// JSON number would drop.
|
||||
auth: { user: this.config.user, otp: totp(this.config.secret), clientTime: Date.now() },
|
||||
},
|
||||
],
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
|
||||
const body = (await response.json().catch(() => undefined)) as
|
||||
| { result?: T; error?: { code?: number; message?: string } }
|
||||
| undefined;
|
||||
// Failures come back as HTTP 200 with an `error` member, so the body is
|
||||
// the thing to check first.
|
||||
if (body?.error) {
|
||||
throw new UntisApiError(body.error.code ?? 0, method, body.error.message ?? 'no message');
|
||||
}
|
||||
if (!response.ok) throw new UntisApiError(0, method, `HTTP ${response.status}`);
|
||||
if (body?.result === undefined) throw new UntisApiError(0, method, 'response carried no result');
|
||||
return body.result;
|
||||
}
|
||||
|
||||
/** Who the key belongs to. Cached; a failure is not, so a fixed key recovers. */
|
||||
identity(): Promise<UntisIdentity> {
|
||||
this.identityPromise ??= this.call<RawUserData>('getUserData2017', {})
|
||||
.then((raw) => {
|
||||
if (raw.masterData) this.masterData = { data: raw.masterData, at: Date.now() };
|
||||
const user = raw.userData ?? {};
|
||||
if (user.elemId === undefined || !user.elemType) {
|
||||
throw new UntisApiError(0, 'getUserData2017', 'response carried no user element');
|
||||
}
|
||||
return {
|
||||
displayName: user.displayName ?? '(unnamed)',
|
||||
elementId: user.elemId,
|
||||
elementType: user.elemType,
|
||||
schoolName: user.schoolName ?? this.config.school,
|
||||
rights: user.rights ?? [],
|
||||
};
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.identityPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
return this.identityPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* The timetable for a date range, with every day in it — including the ones
|
||||
* with no lessons, because "no school today" is an answer and an empty list
|
||||
* is not.
|
||||
*/
|
||||
async timetable(from: string, to: string): Promise<UntisTimetable> {
|
||||
const me = await this.identity();
|
||||
const raw = await this.call<RawTimetable>('getTimetable2017', {
|
||||
id: me.elementId,
|
||||
type: me.elementType,
|
||||
startDate: compactDate(from),
|
||||
endDate: compactDate(to),
|
||||
// Always ask for full master data rather than a delta against a cached
|
||||
// timestamp: the delta's removal semantics are unverified, and the whole
|
||||
// set is one payload of a few hundred kilobytes a handful of times a day.
|
||||
masterDataTimestamp: 0,
|
||||
timetableTimestamp: 0,
|
||||
timetableTimestamps: [],
|
||||
});
|
||||
if (raw.masterData?.subjects) this.masterData = { data: raw.masterData, at: Date.now() };
|
||||
const master = raw.masterData ?? (await this.master());
|
||||
|
||||
const lessons = (raw.timetable?.periods ?? []).map((period) => this.toLesson(period, master));
|
||||
const days: UntisDay[] = [];
|
||||
for (let date = from; date <= to; date = nextDate(date)) {
|
||||
days.push({
|
||||
date,
|
||||
lessons: lessons.filter((lesson) => lesson.date === date).sort(byStart),
|
||||
holidays: holidaysOn(master, date),
|
||||
});
|
||||
}
|
||||
return { from, to, days };
|
||||
}
|
||||
|
||||
/**
|
||||
* Homework set for a date range.
|
||||
*
|
||||
* The range filters by the homework's own dates, not by when it was set, so
|
||||
* a window that ends today shows nothing that is due tomorrow.
|
||||
*/
|
||||
async homework(from: string, to: string): Promise<UntisHomework[]> {
|
||||
const me = await this.identity();
|
||||
const raw = await this.call<{
|
||||
homeWorks?: RawHomework[];
|
||||
lessonsById?: Record<string, { subjectId?: number }>;
|
||||
}>('getHomeWork2017', {
|
||||
id: me.elementId,
|
||||
type: me.elementType,
|
||||
startDate: compactDate(from),
|
||||
endDate: compactDate(to),
|
||||
});
|
||||
const master = await this.master();
|
||||
const subjects = index(master.subjects);
|
||||
return (raw.homeWorks ?? [])
|
||||
.map((item) => {
|
||||
const subjectId = raw.lessonsById?.[String(item.lessonId)]?.subjectId;
|
||||
return toHomework(item, subjectId === undefined ? undefined : named(subjects.get(subjectId)));
|
||||
})
|
||||
.sort((a, b) => a.due.localeCompare(b.due));
|
||||
}
|
||||
|
||||
/**
|
||||
* What was taught in the previous lessons of a period's series — the class
|
||||
* register's "Unterrichtsinhalt", newest first.
|
||||
*
|
||||
* The parameter is a single `periodId`; a list is rejected as "period 0 not
|
||||
* found".
|
||||
*/
|
||||
async lessonTopics(periodId: number): Promise<UntisTopic[]> {
|
||||
const raw = await this.call<{
|
||||
previousTopics?: { text?: string; periodId?: number; startDateTime?: string; endDateTime?: string }[];
|
||||
}>('getLessonTopic2017', { periodId });
|
||||
return (raw.previousTopics ?? [])
|
||||
.filter((topic) => topic.text?.trim())
|
||||
.map((topic) => {
|
||||
const start = splitLocal(topic.startDateTime ?? '');
|
||||
const end = splitLocal(topic.endDateTime ?? '');
|
||||
return {
|
||||
text: topic.text!.trim(),
|
||||
periodId: topic.periodId ?? periodId,
|
||||
date: start.date,
|
||||
start: start.time,
|
||||
end: end.time,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** The school's "Nachrichten des Tages" for one date. Often empty. */
|
||||
async messagesOfDay(date: string): Promise<UntisMessage[]> {
|
||||
const raw = await this.call<{ messages?: { subject?: string; text?: string }[] }>('getMessagesOfDay2017', {
|
||||
date: compactDate(date),
|
||||
});
|
||||
return (raw.messages ?? []).map((message) => ({
|
||||
subject: message.subject?.trim() ?? '',
|
||||
text: message.text?.trim() ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
/** Master data, refreshed at most every few hours: it changes with the school year. */
|
||||
private async master(): Promise<RawMasterData> {
|
||||
if (this.masterData && Date.now() - this.masterData.at < MASTER_DATA_TTL_MS) return this.masterData.data;
|
||||
const raw = await this.call<RawUserData>('getUserData2017', {});
|
||||
const data = raw.masterData ?? {};
|
||||
this.masterData = { data, at: Date.now() };
|
||||
return data;
|
||||
}
|
||||
|
||||
private toLesson(period: RawPeriod, master: RawMasterData): UntisLesson {
|
||||
const start = splitLocal(period.startDateTime);
|
||||
const end = splitLocal(period.endDateTime);
|
||||
const maps = {
|
||||
SUBJECT: index(master.subjects),
|
||||
TEACHER: index(master.teachers),
|
||||
ROOM: index(master.rooms),
|
||||
CLASS: index(master.klassen),
|
||||
};
|
||||
const of = (type: keyof typeof maps): RawElement[] => (period.elements ?? []).filter((e) => e.type === type);
|
||||
const resolve = (type: keyof typeof maps): UntisName[] =>
|
||||
of(type).map((element) => named(maps[type].get(element.id)) ?? { name: `${type.toLowerCase()} #${element.id}` });
|
||||
// Untis expresses "X instead of Y" by keeping the original in orgId.
|
||||
const replacedBy = (type: keyof typeof maps): UntisName[] =>
|
||||
of(type)
|
||||
.filter((element) => element.orgId !== undefined && element.orgId !== element.id)
|
||||
.map((element) => named(maps[type].get(element.orgId!)) ?? { name: `${type.toLowerCase()} #${element.orgId}` });
|
||||
|
||||
const statuses = period.is ?? [];
|
||||
const note = (value: string | undefined): string | undefined => value?.trim() || undefined;
|
||||
return {
|
||||
periodId: period.id,
|
||||
lessonId: period.lessonId,
|
||||
date: start.date,
|
||||
start: start.time,
|
||||
end: end.time,
|
||||
statuses,
|
||||
cancelled: statuses.includes('CANCELLED'),
|
||||
changed: statuses.includes('IRREGULAR') || statuses.includes('SUBSTITUTION'),
|
||||
subjects: resolve('SUBJECT'),
|
||||
teachers: resolve('TEACHER'),
|
||||
rooms: resolve('ROOM'),
|
||||
classes: resolve('CLASS'),
|
||||
replaced: { subjects: replacedBy('SUBJECT'), teachers: replacedBy('TEACHER'), rooms: replacedBy('ROOM') },
|
||||
notes: {
|
||||
lesson: note(period.text?.lesson),
|
||||
substitution: note(period.text?.substitution),
|
||||
info: note(period.text?.info),
|
||||
},
|
||||
homework: (period.homeWorks ?? []).map((item) => toHomework(item, undefined)),
|
||||
exam: note(period.exam?.name) ?? note(period.exam?.text),
|
||||
online: period.isOnlinePeriod === true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Splits `2026-09-21T11:45Z` into date and time — **as local school time**.
|
||||
*
|
||||
* The `Z` is a lie: the school's time grid starts lessons at 08:00 and the API
|
||||
* reports exactly `08:00Z` for them. Parsing these as UTC would shift every
|
||||
* lesson by an hour or two, so the string is taken apart rather than given to
|
||||
* `new Date`.
|
||||
*/
|
||||
export function splitLocal(value: string): { date: string; time: string } {
|
||||
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(value);
|
||||
if (!match) {
|
||||
// Dropping an unparsable lesson would silently shorten a school day; a
|
||||
// changed format has to be visible.
|
||||
throw new Error(`WebUntis returned a timestamp in an unexpected format: ${value}`);
|
||||
}
|
||||
return { date: match[1]!, time: match[2]! };
|
||||
}
|
||||
|
||||
function index(list: RawNamed[] | undefined): Map<number, RawNamed> {
|
||||
return new Map((list ?? []).map((entry) => [entry.id, entry]));
|
||||
}
|
||||
|
||||
function named(entry: RawNamed | undefined): UntisName | undefined {
|
||||
if (!entry) return undefined;
|
||||
const full = [entry.firstName, entry.lastName].filter(Boolean).join(' ').trim();
|
||||
const longName = entry.longName?.trim() || full || undefined;
|
||||
return { name: entry.name, ...(longName ? { longName } : {}) };
|
||||
}
|
||||
|
||||
function toHomework(item: RawHomework, subject: UntisName | undefined): UntisHomework {
|
||||
return {
|
||||
id: item.id,
|
||||
lessonId: item.lessonId,
|
||||
assigned: item.startDate,
|
||||
due: item.endDate,
|
||||
text: item.text?.trim() ?? '',
|
||||
...(item.remark?.trim() ? { remark: item.remark.trim() } : {}),
|
||||
completed: item.completed === true,
|
||||
...(subject ? { subject } : {}),
|
||||
attachments: item.attachments?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function holidaysOn(master: RawMasterData, date: string): UntisHoliday[] {
|
||||
return (master.holidays ?? [])
|
||||
.filter((holiday) => holiday.startDate <= date && date <= holiday.endDate)
|
||||
.map((holiday) => ({
|
||||
name: holiday.name.trim(),
|
||||
longName: holiday.longName.trim(),
|
||||
start: holiday.startDate,
|
||||
end: holiday.endDate,
|
||||
}));
|
||||
}
|
||||
|
||||
function nextDate(date: string): string {
|
||||
return new Date(Date.parse(`${date}T12:00:00Z`) + 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function byStart(a: UntisLesson, b: UntisLesson): number {
|
||||
return a.start.localeCompare(b.start) || a.periodId - b.periodId;
|
||||
}
|
||||
449
src/http/api.ts
449
src/http/api.ts
@@ -2,7 +2,30 @@ import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
import express, { type Request, type Response, type Router } from 'express';
|
||||
import { SchulcloudApiError } from '../core/client.ts';
|
||||
import {
|
||||
compareNames,
|
||||
FileManagerMarkupError,
|
||||
FsError,
|
||||
nameMatcher,
|
||||
type FmFile,
|
||||
type FsErrorCode,
|
||||
type WalkEntry,
|
||||
} from '../core/legacy-files.ts';
|
||||
import { dayLessons, dayNoteSkeleton, dayNoteTitle, missingHeadings } from '../core/day-note.ts';
|
||||
import { isCalendarDate, schoolToday } from '../core/dates.ts';
|
||||
import {
|
||||
dayNotePath,
|
||||
NoteConflict,
|
||||
NoteNotFound,
|
||||
filterNotes,
|
||||
readNoteAt,
|
||||
readNotes,
|
||||
replaceNote,
|
||||
writeNote,
|
||||
} from '../core/notes.ts';
|
||||
import { resolveWithin } from '../core/paths.ts';
|
||||
import { TokenRejected } from '../core/session-token.ts';
|
||||
import type { Services } from '../services.ts';
|
||||
|
||||
/**
|
||||
@@ -14,8 +37,12 @@ import type { Services } from '../services.ts';
|
||||
* from the mirror does neither, which matters for the video files.
|
||||
*
|
||||
* Nothing here can write to Schulcloud. `/refresh` writes only to the Pi's own
|
||||
* index and mirror, and every upstream call it triggers is a GET.
|
||||
* index and mirror, `/token` only to the server's own token, and every upstream
|
||||
* call either triggers is a GET.
|
||||
*/
|
||||
const NO_NOTES_DIR =
|
||||
'This server keeps no notes: NOTES_DIR is not set on it. See docs/NOTES.md.';
|
||||
|
||||
export function createApiRouter(services: Services): Router {
|
||||
const router = express.Router();
|
||||
|
||||
@@ -52,9 +79,22 @@ export function createApiRouter(services: Services): Router {
|
||||
|
||||
router.post('/refresh', express.json({ limit: '16kb' }), async (req: Request, res: Response) => {
|
||||
if (!services.indexer) return res.status(503).json({ error: 'no_index' });
|
||||
const body = (req.body ?? {}) as { courseId?: string; force?: boolean };
|
||||
const body = (req.body ?? {}) as { courseId?: string; force?: boolean; wait?: boolean };
|
||||
try {
|
||||
const result = await services.indexer.refresh(body.courseId ?? 'full', { force: body.force === true });
|
||||
const scope = body.courseId ?? 'full';
|
||||
// `wait: false` answers at once and leaves the caller to poll /status.
|
||||
// Waiting for the result in this request is kept for older clients, but
|
||||
// it cannot outlast a long crawl: Node's fetch abandons a response whose
|
||||
// headers have not arrived within five minutes.
|
||||
if (body.wait === false) {
|
||||
const { run, joined } = services.indexer.start(scope, { force: body.force === true });
|
||||
// The outcome is recorded by the indexer (status().lastResult/lastError);
|
||||
// this only keeps an unawaited failure from becoming an unhandled one.
|
||||
run.catch(() => {});
|
||||
const status = services.indexer.status();
|
||||
return res.status(202).json({ started: !joined, joined, scope, startedAt: status.startedAt ?? null });
|
||||
}
|
||||
const result = await services.indexer.refresh(scope, { force: body.force === true });
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
// A rate-limit refusal is the caller's problem to act on, not a fault.
|
||||
@@ -64,6 +104,113 @@ export function createApiRouter(services: Services): Router {
|
||||
}
|
||||
});
|
||||
|
||||
// --- the file manager ("Dateien"), as a filesystem ----------------------
|
||||
//
|
||||
// Live, not from the index: these answer what the file manager holds now, and
|
||||
// need no database. Paths are the same ones the MCP fs_* tools print.
|
||||
|
||||
router.get('/fs/list', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const node = await services.files.resolve(stringParam(req.query.path) ?? '/');
|
||||
if (node.kind === 'file') return res.json({ path: node.path, kind: 'file', file: node.file });
|
||||
const listing = await services.files.list(node.ref);
|
||||
return res.json({
|
||||
path: node.path,
|
||||
kind: 'directory',
|
||||
area: node.ref.area ?? null,
|
||||
directories: listing.directories.map((entry) => ({ ...entry, path: childPath(node.path, entry.name) })),
|
||||
files: listing.files.map((entry) => ({ ...entry, path: childPath(node.path, entry.name) })),
|
||||
});
|
||||
} catch (error) {
|
||||
return fsFail(res, error, 'fs list');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/fs/tree', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const node = await services.files.resolve(stringParam(req.query.path) ?? '/');
|
||||
if (node.kind === 'file') return res.json({ path: node.path, kind: 'file', file: node.file });
|
||||
const result = await services.files.walk(node, {
|
||||
maxDepth: boundedInt(req.query.depth, 3, 1, 12),
|
||||
maxDirectories: boundedInt(req.query.maxFolders, 200, 1, 1000),
|
||||
});
|
||||
return res.json({
|
||||
path: node.path,
|
||||
kind: 'directory',
|
||||
entries: result.entries.map(treeEntry),
|
||||
visited: result.visited,
|
||||
truncated: result.truncated,
|
||||
failures: result.failures,
|
||||
});
|
||||
} catch (error) {
|
||||
return fsFail(res, error, 'fs tree');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/fs/find', async (req: Request, res: Response) => {
|
||||
const name = stringParam(req.query.name);
|
||||
if (!name) return res.status(400).json({ error: 'bad_request', message: 'Give name.' });
|
||||
try {
|
||||
const node = await services.files.resolve(stringParam(req.query.path) ?? '/');
|
||||
if (node.kind === 'file') return res.json({ path: node.path, kind: 'file', matches: [] });
|
||||
const type = stringParam(req.query.type) ?? 'any';
|
||||
const matches = nameMatcher(name);
|
||||
const result = await services.files.walk(node, {
|
||||
maxDepth: 12,
|
||||
maxDirectories: boundedInt(req.query.maxFolders, 400, 1, 1000),
|
||||
});
|
||||
return res.json({
|
||||
path: node.path,
|
||||
kind: 'directory',
|
||||
matches: result.entries
|
||||
.filter((entry) => (type === 'file' ? entry.file : type === 'folder' ? entry.directory : true))
|
||||
.filter((entry) => matches((entry.file ?? entry.directory)?.name ?? ''))
|
||||
.sort((a, b) => compareNames(a.path, b.path))
|
||||
.map(treeEntry),
|
||||
visited: result.visited,
|
||||
truncated: result.truncated,
|
||||
failures: result.failures,
|
||||
});
|
||||
} catch (error) {
|
||||
return fsFail(res, error, 'fs find');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/fs/file', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const path = stringParam(req.query.path);
|
||||
const id = stringParam(req.query.id);
|
||||
let file: Pick<FmFile, 'id' | 'name'> & Partial<FmFile>;
|
||||
if (path) {
|
||||
const node = await services.files.resolve(path);
|
||||
if (node.kind !== 'file') return res.status(400).json({ error: 'not_a_file', message: `${node.path} is a folder.` });
|
||||
file = node.file;
|
||||
} else if (id && /^[0-9a-f]{24}$/i.test(id)) {
|
||||
file = { id, name: stringParam(req.query.name) ?? id };
|
||||
} else {
|
||||
return res.status(400).json({ error: 'bad_request', message: 'Give path, or id (and name).' });
|
||||
}
|
||||
if (file.blocked) {
|
||||
return res.status(403).json({ error: 'blocked', message: 'The instance virus scanner blocked this file.' });
|
||||
}
|
||||
|
||||
// Streamed straight through rather than buffered: the CLI uses this for
|
||||
// whole folders, and videos routinely exceed any sensible in-memory cap.
|
||||
const signed = await services.client.getFileManagerSignedUrl(file.id, file.name);
|
||||
const upstream = await services.client.openSignedUrl(signed);
|
||||
if (!upstream.body) return res.status(502).json({ error: 'upstream_failed', message: 'empty response' });
|
||||
|
||||
res.setHeader('Content-Type', file.mimeType || upstream.headers.get('content-type') || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', contentDisposition(file.name));
|
||||
const length = upstream.headers.get('content-length');
|
||||
if (length) res.setHeader('Content-Length', length);
|
||||
res.setHeader('X-Schulcloud-Source', 'file-manager');
|
||||
Readable.fromWeb(upstream.body as never).pipe(res);
|
||||
} catch (error) {
|
||||
return fsFail(res, error, 'fs file');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Streams one file. Served from the local mirror when present; otherwise
|
||||
* proxied live, which is what keeps files too large to mirror reachable.
|
||||
@@ -91,16 +238,241 @@ export function createApiRouter(services: Services): Router {
|
||||
});
|
||||
}
|
||||
}
|
||||
const known = await services.store.fileSource(fileId);
|
||||
if (known?.source === 'file-manager') return await proxyFileManager(services, fileId, known, res);
|
||||
return await proxyLive(services, fileId, res);
|
||||
} catch (error) {
|
||||
return fail(res, error, 'file');
|
||||
}
|
||||
});
|
||||
|
||||
// --- the Schulcloud session token -------------------------------------------
|
||||
//
|
||||
// A write, but to this server's own state: the token it reads Schulcloud with.
|
||||
// The only upstream call is the GET /me a replacement must pass first. Works
|
||||
// without an index, since a server without one still needs a token.
|
||||
|
||||
// --- the user's own lesson notes ----------------------------------------
|
||||
//
|
||||
// Read off disk, like the fs_* routes read Schulcloud: no index involved, so
|
||||
// these answer before the first crawl and while Postgres is down. The POST is
|
||||
// the only write in this server that is not the index or its own token, and
|
||||
// it can reach nothing but the notes directory — `writeNote` builds every
|
||||
// path component with `safeComponent` and checks the result with
|
||||
// `resolveWithin`.
|
||||
|
||||
router.get('/notes', 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 });
|
||||
try {
|
||||
const path = stringParam(req.query.path);
|
||||
if (path) return res.json(await readNoteAt(root, path));
|
||||
const notes = filterNotes(await readNotes(root), {
|
||||
...pickParam('subject', req.query.subject),
|
||||
...pickParam('since', req.query.since),
|
||||
...pickParam('until', req.query.until),
|
||||
...pickParam('courseId', req.query.courseId),
|
||||
});
|
||||
const limit = Math.min(Number.parseInt(stringParam(req.query.limit) ?? '', 10) || 500, 2000);
|
||||
return res.json({
|
||||
root,
|
||||
writable: services.config.notesWritable,
|
||||
count: notes.length,
|
||||
// The body is dropped from a listing: a term of notes is megabytes,
|
||||
// and the CLI asks for the ones it wants by path.
|
||||
notes: notes.slice(0, limit).map(({ text, ...rest }) => rest),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof NoteNotFound) return res.status(404).json({ error: 'not_found', message: error.message });
|
||||
return fail(res, error, 'notes');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/notes', express.json({ limit: '1mb' }), 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 Record<string, unknown>;
|
||||
const title = typeof body.title === 'string' ? body.title.trim() : '';
|
||||
const noteText = typeof body.text === 'string' ? body.text : '';
|
||||
if (!title || !noteText.trim()) {
|
||||
return res.status(400).json({ error: 'invalid', message: 'A note needs a title and some text.' });
|
||||
}
|
||||
try {
|
||||
const { note, appended } = await writeNote(root, {
|
||||
title,
|
||||
text: noteText,
|
||||
...pickParam('date', body.date),
|
||||
...pickParam('subject', body.subject),
|
||||
...pickParam('courseId', body.courseId),
|
||||
...pickParam('path', body.path),
|
||||
...pickParam('source', body.source),
|
||||
...(Array.isArray(body.tags) ? { tags: body.tags.filter((tag): tag is string => typeof tag === 'string') } : {}),
|
||||
append: body.append === true,
|
||||
});
|
||||
return res.status(appended ? 200 : 201).json({ ...note, appended });
|
||||
} catch (error) {
|
||||
return fail(res, error, 'save a note');
|
||||
}
|
||||
});
|
||||
|
||||
// --- 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) => {
|
||||
res.json(tokenStatus(services));
|
||||
});
|
||||
|
||||
router.put('/token', express.json({ limit: '16kb' }), async (req: Request, res: Response) => {
|
||||
const jwt = (req.body as { jwt?: unknown } | undefined)?.jwt;
|
||||
if (typeof jwt !== 'string' || !jwt.trim()) {
|
||||
return res.status(400).json({ error: 'missing_jwt', message: 'Send {"jwt": "<the value of the jwt cookie>"}.' });
|
||||
}
|
||||
try {
|
||||
const { changed, persisted } = await services.session.replace(jwt);
|
||||
if (changed) console.log('[schulcloud-mcp] session token replaced at runtime');
|
||||
return res.json({ changed, persisted, ...tokenStatus(services) });
|
||||
} catch (error) {
|
||||
if (error instanceof TokenRejected) return res.status(422).json({ error: error.problem, message: error.message });
|
||||
// Anything else is the instance failing to answer the check. The error
|
||||
// cannot contain the token — SchulcloudApiError carries only a path — but
|
||||
// the response still says no more than that.
|
||||
const detail = error instanceof SchulcloudApiError ? `HTTP ${error.status}` : error instanceof Error ? error.name : 'error';
|
||||
console.error(`[schulcloud-mcp] token check failed: ${detail}`);
|
||||
return res.status(502).json({
|
||||
error: 'check_failed',
|
||||
message: `Schulcloud did not answer the check (${detail}); the token in use is unchanged. Try again shortly.`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// A body that is not JSON would otherwise reach Express's default handler,
|
||||
// which logs it — and here the body is a credential.
|
||||
router.use((error: unknown, _req: Request, res: Response, next: (error?: unknown) => void) => {
|
||||
const type = (error as { type?: string } | undefined)?.type;
|
||||
if (type === 'entity.parse.failed' || type === 'entity.too.large') {
|
||||
res.status(400).json({ error: 'bad_request' });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
function tokenStatus(services: Services) {
|
||||
return { ...services.session.status(), keepalive: services.keepalive?.state() ?? null };
|
||||
}
|
||||
|
||||
/** Falls back to Schulcloud for anything not in the mirror, streaming through. */
|
||||
/** Streams a file-manager file live, via its pre-signed URL; no credentials leave for the storage host. */
|
||||
async function proxyFileManager(
|
||||
services: Services,
|
||||
fileId: string,
|
||||
known: { name: string; mimeType: string; size: number },
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const signed = await services.client.getFileManagerSignedUrl(fileId, known.name);
|
||||
const upstream = await services.client.openSignedUrl(signed);
|
||||
if (!upstream.body) {
|
||||
res.status(502).json({ error: 'upstream_failed', message: 'empty response' });
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', known.mimeType || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', contentDisposition(known.name));
|
||||
const length = upstream.headers.get('content-length');
|
||||
if (length) res.setHeader('Content-Length', length);
|
||||
res.setHeader('X-Schulcloud-Source', 'live');
|
||||
Readable.fromWeb(upstream.body as never).pipe(res);
|
||||
}
|
||||
|
||||
async function proxyLive(services: Services, fileId: string, res: Response): Promise<void> {
|
||||
const record = await services.client.getFileRecord(fileId);
|
||||
if (record.securityCheckStatus === 'blocked') {
|
||||
@@ -143,3 +515,74 @@ function fail(res: Response, error: unknown, what: string): void {
|
||||
console.error(`[schulcloud-mcp] ${what} failed:`, error);
|
||||
if (!res.headersSent) res.status(500).json({ error: 'internal_error' });
|
||||
}
|
||||
|
||||
function stringParam(value: unknown): string | undefined {
|
||||
const first = Array.isArray(value) ? value[0] : value;
|
||||
return typeof first === 'string' && first.length > 0 ? first : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A query or body value as an optional field, so callers can spread it into an
|
||||
* options object without turning "not given" into `undefined` the way an
|
||||
* exactOptionalPropertyTypes build rejects.
|
||||
*/
|
||||
function pickParam<K extends string>(key: K, value: unknown): Partial<Record<K, string>> {
|
||||
const text = stringParam(value);
|
||||
return text ? ({ [key]: text } as Record<K, string>) : {};
|
||||
}
|
||||
|
||||
function boundedInt(value: unknown, fallback: number, min: number, max: number): number {
|
||||
const parsed = Number.parseInt(stringParam(value) ?? '', 10);
|
||||
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
|
||||
}
|
||||
|
||||
function childPath(parent: string, name: string): string {
|
||||
return `${parent === '/' ? '' : parent}/${name}`;
|
||||
}
|
||||
|
||||
/** A walk entry as JSON; `name` travels separately because names may contain "/". */
|
||||
function treeEntry(entry: WalkEntry) {
|
||||
if (entry.directory) {
|
||||
return { type: 'directory', path: entry.path, parentPath: entry.parentPath, depth: entry.depth, id: entry.directory.id, name: entry.directory.name };
|
||||
}
|
||||
const file = entry.file as FmFile;
|
||||
return {
|
||||
type: 'file',
|
||||
path: entry.path,
|
||||
parentPath: entry.parentPath,
|
||||
depth: entry.depth,
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mimeType: file.mimeType ?? null,
|
||||
blocked: file.blocked,
|
||||
};
|
||||
}
|
||||
|
||||
const FS_STATUS: Record<FsErrorCode, number> = {
|
||||
not_found: 404,
|
||||
ambiguous: 409,
|
||||
not_a_directory: 400,
|
||||
not_a_file: 400,
|
||||
not_navigable: 422,
|
||||
};
|
||||
|
||||
function fsFail(res: Response, error: unknown, what: string): void {
|
||||
if (res.headersSent) return;
|
||||
if (error instanceof FsError) {
|
||||
res.status(FS_STATUS[error.code]).json({ error: error.code, message: error.message });
|
||||
return;
|
||||
}
|
||||
if (error instanceof FileManagerMarkupError) {
|
||||
res.status(502).json({ error: 'markup_changed', message: error.message });
|
||||
return;
|
||||
}
|
||||
if (error instanceof SchulcloudApiError) {
|
||||
// 401 upstream is the Pi's session, not the caller's token: say which.
|
||||
const status = error.status === 401 ? 502 : error.status === 403 || error.status === 404 ? error.status : 502;
|
||||
const message = error.status === 401 ? 'The Schulcloud session has expired on the server.' : error.message;
|
||||
res.status(status).json({ error: 'upstream_failed', message });
|
||||
return;
|
||||
}
|
||||
fail(res, error, what);
|
||||
}
|
||||
|
||||
144
src/http/app-page.ts
Normal file
144
src/http/app-page.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
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.
|
||||
*
|
||||
* `app.js` is an ES module and imports the other two, which is also what lets
|
||||
* `markdown.js` — the Markdown the editor reads and writes — be tested under
|
||||
* `node --test` rather than only in a browser.
|
||||
*/
|
||||
|
||||
/** 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' },
|
||||
'/editor.js': { file: 'editor.js', type: 'text/javascript; charset=utf-8' },
|
||||
'/markdown.js': { file: 'markdown.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|editor\.js|markdown\.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 };
|
||||
}
|
||||
309
src/http/app/app.css
Normal file
309
src/http/app/app.css
Normal file
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
* 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 toolbar ------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
* One row that scrolls sideways rather than wrapping into two: a second row
|
||||
* would take a line of editor away from every note to hold buttons that are
|
||||
* used once a lesson, and a thumb swipes a row far more easily than it hunts
|
||||
* through a grid.
|
||||
*/
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.toolbar::-webkit-scrollbar { display: none; }
|
||||
|
||||
.toolbar button {
|
||||
flex: 0 0 auto;
|
||||
min-width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toolbar button[aria-pressed="true"] {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--card));
|
||||
}
|
||||
|
||||
.toolbar button code { font-family: ui-monospace, monospace; font-size: 0.85rem; }
|
||||
.sep { flex: 0 0 auto; width: 1px; height: 1.5rem; background: var(--line); margin: 0 0.15rem; }
|
||||
|
||||
/* --- the editor -------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
* The formatted document. It is the note as it will read, not as it is stored
|
||||
* — the file underneath is still Markdown, and `MD` in the toolbar shows it.
|
||||
*/
|
||||
.editor {
|
||||
flex: 1;
|
||||
min-height: 12rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-size: 1rem;
|
||||
line-height: 1.55;
|
||||
/* A long URL or a wide table must not push the page sideways. */
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.editor:focus-visible { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
|
||||
.editor.empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editor > :first-child { margin-top: 0; }
|
||||
.editor > :last-child { margin-bottom: 0; }
|
||||
.editor p { margin: 0 0 0.75rem; }
|
||||
|
||||
/* A lesson heading is the note's structure — each one is indexed as its own
|
||||
lesson — so it is given a rule to sit on rather than just a larger size. */
|
||||
.editor h2 {
|
||||
margin: 1.25rem 0 0.5rem;
|
||||
padding-bottom: 0.2rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.editor h1 { font-size: 1.25rem; margin: 1.25rem 0 0.5rem; }
|
||||
.editor h3, .editor h4, .editor h5, .editor h6 { margin: 1rem 0 0.35rem; font-size: 1rem; }
|
||||
|
||||
.editor ul, .editor ol { margin: 0 0 0.75rem; padding-left: 1.4rem; }
|
||||
.editor li { margin: 0.15rem 0; }
|
||||
.editor li.task { list-style: none; margin-left: -1.2rem; }
|
||||
.editor li.task input { margin-right: 0.4rem; }
|
||||
|
||||
.editor blockquote {
|
||||
margin: 0 0 0.75rem;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 3px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.editor code {
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--card);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.editor pre {
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--card);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.editor pre code { padding: 0; background: none; }
|
||||
.editor hr { border: 0; border-top: 1px solid var(--line); margin: 1rem 0; }
|
||||
.editor a { color: var(--accent); }
|
||||
|
||||
/* A table wider than the phone scrolls inside the note rather than stretching
|
||||
it: the caret has to stay reachable. */
|
||||
.editor table { display: block; overflow-x: auto; border-collapse: collapse; margin: 0 0 0.75rem; font-size: 0.9rem; }
|
||||
.editor th, .editor td { border: 1px solid var(--line); padding: 0.3rem 0.5rem; text-align: left; min-width: 3rem; }
|
||||
.editor th { background: var(--card); }
|
||||
|
||||
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: this is the Markdown view, 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; }
|
||||
534
src/http/app/app.js
Normal file
534
src/http/app/app.js
Normal file
@@ -0,0 +1,534 @@
|
||||
import { createEditor } from './editor.js';
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*
|
||||
* What the person sees is formatted text with a toolbar; what is written to
|
||||
* disk is Markdown. `editor.js` is the whole of that translation — everything
|
||||
* here deals in Markdown strings and never touches the document.
|
||||
*/
|
||||
|
||||
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'),
|
||||
toolbar: document.getElementById('toolbar'),
|
||||
editor: document.getElementById('editor'),
|
||||
source: document.getElementById('source'),
|
||||
editorHint: document.getElementById('editor-hint'),
|
||||
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,
|
||||
};
|
||||
|
||||
/**
|
||||
* The formatted editor over the two elements that hold a note.
|
||||
*
|
||||
* It owns the document and the toolbar; this file only ever asks it for
|
||||
* Markdown and hands it Markdown back.
|
||||
*/
|
||||
const editor = createEditor({
|
||||
rich: ui.editor,
|
||||
source: ui.source,
|
||||
toolbar: ui.toolbar,
|
||||
onInput: markDirty,
|
||||
onModeChange: (mode) => {
|
||||
// Switching by hand is not a warning, so the automatic one goes away.
|
||||
hint(mode === 'source' ? 'Markdown-Ansicht. „MD" führt zurück.' : '');
|
||||
},
|
||||
});
|
||||
|
||||
function hint(message) {
|
||||
ui.editorHint.textContent = message;
|
||||
ui.editorHint.hidden = !message;
|
||||
}
|
||||
|
||||
// --- 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(text) {
|
||||
try {
|
||||
const value = text === undefined ? editor.getMarkdown() : text;
|
||||
localStorage.setItem(draftKey(day.date), JSON.stringify({ text: 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;
|
||||
editor.setEnabled(false);
|
||||
hint('');
|
||||
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) {
|
||||
editor.setMarkdown('');
|
||||
editor.setEnabled(false);
|
||||
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);
|
||||
editor.setEnabled(true);
|
||||
editor.setMarkdown(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() !== '';
|
||||
|
||||
editor.setEnabled(true);
|
||||
// The server's text first, and what the editor makes of it is the baseline.
|
||||
// Opening a note the editor would tidy — a table typed unevenly, `*` for
|
||||
// italics — must not count as an edit, or simply looking at a day would
|
||||
// rewrite the file.
|
||||
const loaded = editor.setMarkdown(server);
|
||||
day.saved = info.exists ? editor.getMarkdown() : '';
|
||||
if (useDraft) editor.setMarkdown(draft.text);
|
||||
day.dirty = editor.getMarkdown() !== day.saved;
|
||||
|
||||
// One note in a hundred: something the formatted view cannot hold without
|
||||
// changing it. It opens as Markdown rather than being quietly reduced.
|
||||
hint(
|
||||
!loaded.faithful
|
||||
? 'Diese Notiz enthält Formatierung, die die formatierte Ansicht nicht unverändert halten kann — deshalb Markdown.'
|
||||
: editor.mode === 'source'
|
||||
? 'Markdown-Ansicht. „MD" führt zurück.'
|
||||
: '',
|
||||
);
|
||||
|
||||
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() {
|
||||
const text = editor.getMarkdown();
|
||||
day.dirty = text !== day.saved;
|
||||
saveDraft(text);
|
||||
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 = editor.getMarkdown();
|
||||
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.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.
|
||||
editor.append(day.missing);
|
||||
day.missing = '';
|
||||
ui.fill.hidden = true;
|
||||
});
|
||||
|
||||
// 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);
|
||||
})();
|
||||
519
src/http/app/editor.js
Normal file
519
src/http/app/editor.js
Normal file
@@ -0,0 +1,519 @@
|
||||
import { markdownFromDom, markdownToHtml } from './markdown.js';
|
||||
|
||||
/*
|
||||
* The formatted editor.
|
||||
*
|
||||
* Notes are written in a lesson, with a thumb, on a phone. Typing `##` and
|
||||
* `**` while a teacher talks is not note-taking, so what this shows is the
|
||||
* formatted text and a toolbar — and what it writes to disk is still Markdown,
|
||||
* because that is what the indexer reads and what outlives this app.
|
||||
*
|
||||
* `contenteditable` plus `document.execCommand` rather than a framework or an
|
||||
* editor library: the content security policy allows no outside script, and
|
||||
* this app has no build step to bundle one in. execCommand is deprecated on
|
||||
* paper and universally implemented in practice — including on iOS Safari,
|
||||
* which is the browser that actually matters here — and it brings selection
|
||||
* handling, native undo and the software keyboard's own behaviour with it.
|
||||
* A hand-written selection engine would be a much larger thing to get wrong.
|
||||
*
|
||||
* Whatever the browser leaves behind in the document is the serializer's
|
||||
* problem, not this file's: `markdownFromDom` is deliberately tolerant, and
|
||||
* everything typed, pasted or produced by a command is reduced to the
|
||||
* supported subset on the way to the file.
|
||||
*/
|
||||
|
||||
export function createEditor(options) {
|
||||
const rich = options.rich;
|
||||
const source = options.source;
|
||||
const toolbar = options.toolbar;
|
||||
const onInput = options.onInput ?? (() => {});
|
||||
const onModeChange = options.onModeChange ?? (() => {});
|
||||
|
||||
let mode = 'rich';
|
||||
// Which view the person last chose. Moving to another day should not undo
|
||||
// that choice, so only a note the formatted view cannot hold overrides it.
|
||||
let preferred = 'rich';
|
||||
let enabled = true;
|
||||
|
||||
// execCommand's default is to write inline styles (`<span style="font-weight:
|
||||
// bold">`). Tags survive the trip to Markdown far more reliably, and the
|
||||
// serializer would only have to undo the styles anyway.
|
||||
try {
|
||||
document.execCommand('styleWithCSS', false, false);
|
||||
} catch {
|
||||
// Not every engine has it, and none of them needs it to work.
|
||||
}
|
||||
|
||||
// --- reading and writing --------------------------------------------
|
||||
|
||||
function getMarkdown() {
|
||||
return mode === 'source' ? source.value.trim() : markdownFromDom(rich);
|
||||
}
|
||||
|
||||
function setMarkdown(text) {
|
||||
const value = String(text ?? '');
|
||||
// A note whose formatting this editor cannot hold opens as Markdown
|
||||
// rather than being quietly rewritten into something smaller.
|
||||
const faithful = isStable(value);
|
||||
setMode(faithful ? preferred : 'source', { silent: true });
|
||||
source.value = value;
|
||||
rich.innerHTML = markdownToHtml(value);
|
||||
ensureTrailingParagraph();
|
||||
updatePlaceholder();
|
||||
return { faithful };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the round trip settles.
|
||||
*
|
||||
* One pass may tidy the note — `*a*` becomes `_a_`, a ragged table lines up
|
||||
* — and that is fine, because a note is only ever rewritten once it has been
|
||||
* edited. A second pass that changes something again is not fine: it means
|
||||
* this editor does not understand the note, and every save would erode it a
|
||||
* little further. That is the case where the Markdown view is the honest
|
||||
* answer.
|
||||
*/
|
||||
function isStable(text) {
|
||||
const once = markdownFromDom(parse(text));
|
||||
return markdownFromDom(parse(once)) === once;
|
||||
}
|
||||
|
||||
function parse(text) {
|
||||
const holder = document.createElement('div');
|
||||
holder.innerHTML = markdownToHtml(text);
|
||||
return holder;
|
||||
}
|
||||
|
||||
// --- the two modes ---------------------------------------------------
|
||||
|
||||
function setMode(next, config) {
|
||||
if (next === mode) return;
|
||||
// Carry the text across, so a toggle never costs a word.
|
||||
if (next === 'source') source.value = markdownFromDom(rich);
|
||||
else {
|
||||
rich.innerHTML = markdownToHtml(source.value);
|
||||
ensureTrailingParagraph();
|
||||
}
|
||||
|
||||
mode = next;
|
||||
rich.hidden = mode !== 'rich';
|
||||
source.hidden = mode !== 'source';
|
||||
toolbar.querySelectorAll('[data-command]').forEach((button) => {
|
||||
if (button.dataset.command !== 'mode') button.disabled = mode === 'source';
|
||||
});
|
||||
const toggle = toolbar.querySelector('[data-command="mode"]');
|
||||
if (toggle) toggle.setAttribute('aria-pressed', String(mode === 'source'));
|
||||
updatePlaceholder();
|
||||
if (!config || !config.silent) onModeChange(mode);
|
||||
}
|
||||
|
||||
function setEnabled(value) {
|
||||
enabled = value;
|
||||
rich.contentEditable = value ? 'true' : 'false';
|
||||
source.disabled = !value;
|
||||
toolbar.querySelectorAll('button').forEach((button) => {
|
||||
button.disabled = !value || (mode === 'source' && button.dataset.command !== 'mode');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A line after the last block, so there is somewhere to go.
|
||||
*
|
||||
* A table, a code block or a rule at the very end of a `contenteditable`
|
||||
* element is a dead end: there is no node after it to put the caret in, and
|
||||
* no key that makes one — the note simply cannot be continued. Every engine
|
||||
* behaves this way, and every editor works around it the same way. The
|
||||
* paragraph is empty, so it serializes to nothing and never reaches the file.
|
||||
*/
|
||||
const TRAILING_TRAP = /^(TABLE|PRE|HR|BLOCKQUOTE|UL|OL)$/;
|
||||
|
||||
function ensureTrailingParagraph() {
|
||||
const last = rich.lastElementChild;
|
||||
if (!last || !TRAILING_TRAP.test(last.nodeName)) return;
|
||||
const paragraph = document.createElement('p');
|
||||
paragraph.appendChild(document.createElement('br'));
|
||||
rich.appendChild(paragraph);
|
||||
}
|
||||
|
||||
function updatePlaceholder() {
|
||||
rich.classList.toggle('empty', rich.textContent.trim() === '' && rich.children.length <= 1);
|
||||
}
|
||||
|
||||
// --- commands --------------------------------------------------------
|
||||
|
||||
const commands = {
|
||||
bold: () => document.execCommand('bold'),
|
||||
italic: () => document.execCommand('italic'),
|
||||
strike: () => document.execCommand('strikeThrough'),
|
||||
h2: () => toggleBlock('H2'),
|
||||
h3: () => toggleBlock('H3'),
|
||||
quote: () => toggleBlock('BLOCKQUOTE'),
|
||||
ul: () => document.execCommand('insertUnorderedList'),
|
||||
ol: () => document.execCommand('insertOrderedList'),
|
||||
task: insertTask,
|
||||
code: insertCode,
|
||||
link: insertLink,
|
||||
table: insertTable,
|
||||
mode: () => {
|
||||
preferred = mode === 'rich' ? 'source' : 'rich';
|
||||
setMode(preferred);
|
||||
},
|
||||
};
|
||||
|
||||
/** A second press on the same button goes back to ordinary text. */
|
||||
function toggleBlock(tag) {
|
||||
const current = blockAt();
|
||||
document.execCommand('formatBlock', false, current === tag ? 'P' : tag);
|
||||
}
|
||||
|
||||
function blockAt() {
|
||||
let node = selectionNode();
|
||||
while (node && node !== rich) {
|
||||
if (node.nodeType === 1 && /^(P|DIV|H[1-6]|BLOCKQUOTE|LI|PRE|TD|TH)$/.test(node.nodeName)) return node.nodeName;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function selectionNode() {
|
||||
const selection = document.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return undefined;
|
||||
const node = selection.getRangeAt(0).startContainer;
|
||||
return rich.contains(node) ? node : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A checkbox item.
|
||||
*
|
||||
* Built as a list first, so the browser handles the splitting and merging
|
||||
* of the item the cursor is in, and then given its box.
|
||||
*/
|
||||
function insertTask() {
|
||||
const item = itemAt();
|
||||
if (item && firstCheckbox(item)) {
|
||||
// Already a task: take the box away rather than adding a second.
|
||||
firstCheckbox(item).remove();
|
||||
return;
|
||||
}
|
||||
if (!item) document.execCommand('insertUnorderedList');
|
||||
const target = itemAt();
|
||||
if (!target || firstCheckbox(target)) return;
|
||||
target.classList.add('task');
|
||||
target.insertBefore(checkbox(false), target.firstChild);
|
||||
}
|
||||
|
||||
function itemAt() {
|
||||
let node = selectionNode();
|
||||
while (node && node !== rich) {
|
||||
if (node.nodeType === 1 && node.nodeName === 'LI') return node;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function checkbox(checked) {
|
||||
const box = document.createElement('input');
|
||||
box.type = 'checkbox';
|
||||
box.contentEditable = 'false';
|
||||
// The attribute, not just the property: the serializer reads the
|
||||
// document, and a property set by a click leaves no trace in it.
|
||||
if (checked) box.setAttribute('checked', '');
|
||||
return box;
|
||||
}
|
||||
|
||||
function firstCheckbox(item) {
|
||||
const first = item.firstElementChild;
|
||||
return first && first.nodeName === 'INPUT' && first.type === 'checkbox' ? first : null;
|
||||
}
|
||||
|
||||
function insertCode() {
|
||||
const selection = document.getSelection();
|
||||
const text = selection ? selection.toString() : '';
|
||||
// `insertHTML` and not a wrapping node, so the caret lands inside the
|
||||
// new element and the browser records one undo step.
|
||||
document.execCommand('insertHTML', false, '<code>' + escapeHtml(text || 'Code') + '</code> ');
|
||||
}
|
||||
|
||||
function insertLink() {
|
||||
const selection = document.getSelection();
|
||||
const label = selection ? selection.toString() : '';
|
||||
const href = window.prompt('Adresse des Links', 'https://');
|
||||
if (!href || href === 'https://') return;
|
||||
if (!/^(https?:|mailto:|tel:)/i.test(href)) {
|
||||
window.alert('Nur http, https, mailto und tel.');
|
||||
return;
|
||||
}
|
||||
if (label) document.execCommand('createLink', false, href);
|
||||
else document.execCommand('insertHTML', false, '<a href="' + escapeHtml(href) + '">' + escapeHtml(href) + '</a> ');
|
||||
}
|
||||
|
||||
/**
|
||||
* A table, or one more row of the table already under the cursor.
|
||||
*
|
||||
* Two jobs on one button because a phone has no Tab key, and adding a row is
|
||||
* what anyone wants far more often than a second table inside the first.
|
||||
* `reflect` renames the button so it says which one it will do.
|
||||
*/
|
||||
function insertTable() {
|
||||
const table = tableAt();
|
||||
if (table) {
|
||||
addRow(table);
|
||||
return;
|
||||
}
|
||||
const head = '<tr><th><br></th><th><br></th></tr>';
|
||||
const row = '<tr><td><br></td><td><br></td></tr>';
|
||||
document.execCommand(
|
||||
'insertHTML',
|
||||
false,
|
||||
'<table><thead>' + head + '</thead><tbody>' + row + row + '</tbody></table><p><br></p>',
|
||||
);
|
||||
}
|
||||
|
||||
function cellAt() {
|
||||
let node = selectionNode();
|
||||
while (node && node !== rich) {
|
||||
if (node.nodeType === 1 && (node.nodeName === 'TD' || node.nodeName === 'TH')) return node;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function tableAt() {
|
||||
let node = selectionNode();
|
||||
while (node && node !== rich) {
|
||||
if (node.nodeType === 1 && node.nodeName === 'TABLE') return node;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** One more row, as wide as the table, with the caret in its first cell. */
|
||||
function addRow(table) {
|
||||
const rows = table.querySelectorAll('tr');
|
||||
const width = Math.max(1, ...Array.from(rows, (row) => row.children.length));
|
||||
const body = table.querySelector('tbody') ?? table;
|
||||
const row = document.createElement('tr');
|
||||
for (let i = 0; i < width; i++) {
|
||||
const cell = document.createElement('td');
|
||||
// An empty cell with nothing in it cannot be clicked into in Gecko;
|
||||
// the break gives the caret somewhere to stand.
|
||||
cell.appendChild(document.createElement('br'));
|
||||
row.appendChild(cell);
|
||||
}
|
||||
body.appendChild(row);
|
||||
placeCaret(row.firstElementChild);
|
||||
}
|
||||
|
||||
function placeCaret(node) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
range.collapse(true);
|
||||
const selection = document.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
// --- input -----------------------------------------------------------
|
||||
|
||||
function notify() {
|
||||
ensureTrailingParagraph();
|
||||
updatePlaceholder();
|
||||
onInput();
|
||||
}
|
||||
|
||||
rich.addEventListener('input', notify);
|
||||
source.addEventListener('input', notify);
|
||||
|
||||
// A checkbox is the one control inside the document: its state has to reach
|
||||
// the markup, or the save would not see it.
|
||||
rich.addEventListener('change', (event) => {
|
||||
const target = event.target;
|
||||
if (!target || target.nodeName !== 'INPUT' || target.type !== 'checkbox') return;
|
||||
if (target.checked) target.setAttribute('checked', '');
|
||||
else target.removeAttribute('checked');
|
||||
notify();
|
||||
});
|
||||
|
||||
/**
|
||||
* Pasted content goes through Markdown before it reaches the document.
|
||||
*
|
||||
* A paste from a web page or a Word document carries fonts, colours,
|
||||
* classes and occasionally script. Converting it to Markdown and parsing it
|
||||
* back reduces it to exactly what this editor supports — the same subset the
|
||||
* file will hold — and is the one place where sanitising and formatting are
|
||||
* the same operation.
|
||||
*/
|
||||
rich.addEventListener('paste', (event) => {
|
||||
if (!enabled || mode !== 'rich') return;
|
||||
const data = event.clipboardData;
|
||||
if (!data) return;
|
||||
const html = data.getData('text/html');
|
||||
const text = data.getData('text/plain');
|
||||
if (!html && !text) return;
|
||||
event.preventDefault();
|
||||
|
||||
let markdown;
|
||||
if (html) {
|
||||
const holder = document.createElement('div');
|
||||
// Never assigned to a live document: this element is detached, and
|
||||
// what comes out of it is Markdown, not markup.
|
||||
holder.innerHTML = html;
|
||||
markdown = markdownFromDom(holder);
|
||||
} else {
|
||||
markdown = text;
|
||||
}
|
||||
document.execCommand('insertHTML', false, markdownToHtml(markdown));
|
||||
notify();
|
||||
});
|
||||
|
||||
rich.addEventListener('keydown', (event) => {
|
||||
const modifier = event.metaKey || event.ctrlKey;
|
||||
if (modifier && !event.altKey) {
|
||||
const key = event.key.toLowerCase();
|
||||
const shortcut = { b: 'bold', i: 'italic', k: 'link', e: 'code' }[key];
|
||||
if (shortcut) {
|
||||
event.preventDefault();
|
||||
run(shortcut);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Tab walks the cells, and a Tab out of the last one adds a row. This is
|
||||
// what every table anywhere does, and without it the only way to add a
|
||||
// row on a keyboard would be the toolbar.
|
||||
if (event.key === 'Tab' && !modifier) {
|
||||
const cell = cellAt();
|
||||
if (cell) {
|
||||
event.preventDefault();
|
||||
const cells = Array.from(cell.closest('table').querySelectorAll('th, td'));
|
||||
const next = cells[cells.indexOf(cell) + (event.shiftKey ? -1 : 1)];
|
||||
if (next) placeCaret(next);
|
||||
else if (!event.shiftKey) addRow(cell.closest('table'));
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// The way out of anything: a new paragraph after the block the cursor is
|
||||
// in, however deep in a table or a quote it sits.
|
||||
if (event.key === 'Enter' && modifier) {
|
||||
event.preventDefault();
|
||||
let block = selectionNode();
|
||||
while (block && block.parentNode !== rich) block = block.parentNode;
|
||||
const paragraph = document.createElement('p');
|
||||
paragraph.appendChild(document.createElement('br'));
|
||||
if (block) block.after(paragraph);
|
||||
else rich.appendChild(paragraph);
|
||||
placeCaret(paragraph);
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter at the end of a task item continues the list as tasks; the
|
||||
// browser would give the new item no box.
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
const item = itemAt();
|
||||
if (item && firstCheckbox(item)) {
|
||||
window.setTimeout(() => {
|
||||
const next = itemAt();
|
||||
if (next && next !== item && !firstCheckbox(next) && next.textContent.trim() === '') {
|
||||
next.classList.add('task');
|
||||
next.insertBefore(checkbox(false), next.firstChild);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- the toolbar -----------------------------------------------------
|
||||
|
||||
function run(name) {
|
||||
const command = commands[name];
|
||||
if (!command) return;
|
||||
if (name !== 'mode') {
|
||||
if (!enabled || mode !== 'rich') return;
|
||||
rich.focus();
|
||||
}
|
||||
command();
|
||||
notify();
|
||||
reflect();
|
||||
}
|
||||
|
||||
toolbar.addEventListener('mousedown', (event) => {
|
||||
// The selection must survive the press, or every command would apply to
|
||||
// nothing. Touch devices fire this too, ahead of the click.
|
||||
if (event.target.closest('[data-command]')) event.preventDefault();
|
||||
});
|
||||
|
||||
toolbar.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-command]');
|
||||
if (!button) return;
|
||||
event.preventDefault();
|
||||
run(button.dataset.command);
|
||||
});
|
||||
|
||||
/** Which buttons are "on" for the cursor's position. */
|
||||
function reflect() {
|
||||
if (mode !== 'rich') return;
|
||||
const block = blockAt();
|
||||
const states = {
|
||||
bold: query('bold'),
|
||||
italic: query('italic'),
|
||||
strike: query('strikeThrough'),
|
||||
h2: block === 'H2',
|
||||
h3: block === 'H3',
|
||||
quote: block === 'BLOCKQUOTE',
|
||||
ul: query('insertUnorderedList'),
|
||||
ol: query('insertOrderedList'),
|
||||
};
|
||||
for (const [name, active] of Object.entries(states)) {
|
||||
const button = toolbar.querySelector('[data-command="' + name + '"]');
|
||||
if (button) button.setAttribute('aria-pressed', String(Boolean(active)));
|
||||
}
|
||||
|
||||
const table = toolbar.querySelector('[data-command="table"]');
|
||||
if (table) {
|
||||
const inside = Boolean(tableAt());
|
||||
const label = inside ? 'Zeile anfügen' : 'Tabelle';
|
||||
table.setAttribute('aria-label', label);
|
||||
table.title = inside ? label + ' (oder Tab in der letzten Zelle)' : label;
|
||||
}
|
||||
}
|
||||
|
||||
function query(command) {
|
||||
try {
|
||||
return document.queryCommandState(command);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('selectionchange', () => {
|
||||
if (selectionNode()) reflect();
|
||||
});
|
||||
|
||||
// --- what the app calls ----------------------------------------------
|
||||
|
||||
return {
|
||||
getMarkdown,
|
||||
setMarkdown,
|
||||
setEnabled,
|
||||
get mode() {
|
||||
return mode;
|
||||
},
|
||||
focus() {
|
||||
(mode === 'rich' ? rich : source).focus();
|
||||
},
|
||||
/** Adds Markdown at the end — how the day's missing lessons arrive. */
|
||||
append(markdown) {
|
||||
const current = getMarkdown();
|
||||
const next = (current ? current.replace(/\s*$/, '') + '\n\n' : '') + markdown;
|
||||
if (mode === 'source') source.value = next;
|
||||
else rich.innerHTML = markdownToHtml(next);
|
||||
notify();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
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 |
117
src/http/app/index.html
Normal file
117
src/http/app/index.html
Normal file
@@ -0,0 +1,117 @@
|
||||
<!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>
|
||||
|
||||
<!-- The toolbar writes the Markdown so nobody has to type it. Every button
|
||||
carries a word as well as a glyph, because the glyph is the thing a
|
||||
screen reader cannot read and a stranger cannot guess. -->
|
||||
<div id="toolbar" class="toolbar" role="toolbar" aria-label="Formatierung">
|
||||
<button type="button" data-command="h2" aria-pressed="false" aria-label="Stunde (Überschrift)" title="Stunde (Überschrift)"><b>H2</b></button>
|
||||
<button type="button" data-command="h3" aria-pressed="false" aria-label="Zwischenüberschrift" title="Zwischenüberschrift"><b>H3</b></button>
|
||||
<span class="sep" aria-hidden="true"></span>
|
||||
<button type="button" data-command="bold" aria-pressed="false" aria-label="Fett" title="Fett (Strg+B)"><b>F</b></button>
|
||||
<button type="button" data-command="italic" aria-pressed="false" aria-label="Kursiv" title="Kursiv (Strg+I)"><i>K</i></button>
|
||||
<button type="button" data-command="strike" aria-pressed="false" aria-label="Durchgestrichen" title="Durchgestrichen"><s>S</s></button>
|
||||
<button type="button" data-command="code" aria-label="Code" title="Code (Strg+E)"><code><></code></button>
|
||||
<span class="sep" aria-hidden="true"></span>
|
||||
<button type="button" data-command="ul" aria-pressed="false" aria-label="Aufzählung" title="Aufzählung">• —</button>
|
||||
<button type="button" data-command="ol" aria-pressed="false" aria-label="Nummerierte Liste" title="Nummerierte Liste">1. —</button>
|
||||
<button type="button" data-command="task" aria-label="Kästchen zum Abhaken" title="Kästchen zum Abhaken">☐</button>
|
||||
<button type="button" data-command="quote" aria-pressed="false" aria-label="Zitat" title="Zitat">❝</button>
|
||||
<span class="sep" aria-hidden="true"></span>
|
||||
<button type="button" data-command="link" aria-label="Link" title="Link (Strg+K)">🔗</button>
|
||||
<button type="button" data-command="table" aria-label="Tabelle" title="Tabelle">▦</button>
|
||||
<span class="sep" aria-hidden="true"></span>
|
||||
<button type="button" data-command="mode" aria-pressed="false" aria-label="Markdown bearbeiten" title="Markdown bearbeiten">MD</button>
|
||||
</div>
|
||||
|
||||
<p id="editor-hint" class="hint" role="status" hidden></p>
|
||||
|
||||
<!-- The formatted document, and the same note as Markdown. Exactly one of
|
||||
the two is visible; both hold the whole note. -->
|
||||
<div id="editor" class="editor" contenteditable="true" spellcheck="true" autocapitalize="sentences"
|
||||
role="textbox" aria-multiline="true" aria-label="Notizen des Tages"
|
||||
data-placeholder="Noch nichts für diesen Tag."></div>
|
||||
<textarea id="source" class="source" hidden spellcheck="false" autocapitalize="off"
|
||||
aria-label="Notizen des Tages als Markdown"></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 type="module" 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" }
|
||||
]
|
||||
}
|
||||
652
src/http/app/markdown.js
Normal file
652
src/http/app/markdown.js
Normal file
@@ -0,0 +1,652 @@
|
||||
/*
|
||||
* Markdown in, formatted text out, and back again.
|
||||
*
|
||||
* The notes are Markdown files — that is what the indexer reads, what
|
||||
* `subjectFromHeading` takes a lesson apart with, and what survives this
|
||||
* project. The editor shows them as formatted text anyway, so this module is
|
||||
* the hinge: `markdownToHtml` on the way into the editor, `markdownFromDom` on
|
||||
* the way back out to the file.
|
||||
*
|
||||
* Three properties matter more than completeness, because what passes through
|
||||
* here is the only record of what was said in a lesson:
|
||||
*
|
||||
* - **Round-trip stability.** `fromDom(toHtml(x))` may tidy `x` once — `*a*`
|
||||
* becomes `_a_`, a ragged table lines up — but doing it again must change
|
||||
* nothing. `editor.js` checks exactly that before it opens a note in
|
||||
* formatted mode, and falls back to the Markdown view when it does not hold.
|
||||
* - **Nothing is dropped.** An element this module does not model keeps its
|
||||
* words and loses its tag. A note is better off plain than short.
|
||||
* - **No HTML is trusted.** `markdownToHtml` escapes everything that is not a
|
||||
* construct it produced itself, so a note containing `<script>` is text, not
|
||||
* script. Pasted HTML never reaches the document either: it is converted to
|
||||
* Markdown first and parsed back, which reduces it to the subset below.
|
||||
*
|
||||
* The subset is what these notes are made of: headings, paragraphs, bold,
|
||||
* italic, strikethrough, code (inline and fenced), links, bullet / numbered /
|
||||
* task lists with nesting, blockquotes, tables and rules. Underline is
|
||||
* deliberately absent — Markdown has no way to write it, so the toolbar does
|
||||
* not offer what the file cannot keep.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Where a code span sat while the emphasis rules ran over the line.
|
||||
*
|
||||
* A control character, because it is the one thing a note cannot contain: the
|
||||
* store strips NUL out of extracted text, and nothing types one.
|
||||
*/
|
||||
const PLACEHOLDER = '\u0000';
|
||||
const PLACEHOLDERS = /\u0000(\d+)\u0000/g;
|
||||
|
||||
/** Ordered and bullet items, with their indentation and marker. */
|
||||
const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/;
|
||||
/** How far a continuation line must be indented to belong to the item above. */
|
||||
const CONTINUATION = 2;
|
||||
|
||||
// --- Markdown → HTML -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* A note's body as HTML for the editor.
|
||||
*
|
||||
* The output is the only HTML the editor ever starts from, which is what makes
|
||||
* the serializer's job finite.
|
||||
*/
|
||||
export function markdownToHtml(markdown) {
|
||||
const lines = String(markdown ?? '')
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split('\n')
|
||||
.map(expandLeadingTabs);
|
||||
return parseBlocks(lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tabs only in the indentation, and only there.
|
||||
*
|
||||
* Indentation is measured in columns to decide what nests inside what, so a
|
||||
* tab has to become a known number of spaces first. Tabs inside the text are
|
||||
* left alone — in a code block they are content.
|
||||
*/
|
||||
function expandLeadingTabs(line) {
|
||||
const match = /^[ \t]+/.exec(line);
|
||||
if (!match) return line;
|
||||
return match[0].replace(/\t/g, ' ') + line.slice(match[0].length);
|
||||
}
|
||||
|
||||
function parseBlocks(lines) {
|
||||
const out = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fence = /^ {0,3}(```+|~~~+)\s*([A-Za-z0-9_+#-]*)\s*$/.exec(line);
|
||||
if (fence) {
|
||||
const closing = new RegExp('^ {0,3}' + fence[1][0] + '{' + fence[1].length + ',}\\s*$');
|
||||
const body = [];
|
||||
i++;
|
||||
while (i < lines.length && !closing.test(lines[i])) {
|
||||
body.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
// An unclosed fence still ends the block; the note is what it is.
|
||||
i++;
|
||||
const language = fence[2] ? ' class="language-' + escapeHtml(fence[2]) + '"' : '';
|
||||
out.push('<pre><code' + language + '>' + escapeHtml(body.join('\n')) + '</code></pre>');
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = /^ {0,3}(#{1,6})\s+(.*?)\s*#*$/.exec(line);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
out.push('<h' + level + '>' + inlineToHtml(heading[2]) + '</h' + level + '>');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isRule(line)) {
|
||||
out.push('<hr>');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^ {0,3}>/.test(line)) {
|
||||
const body = [];
|
||||
while (i < lines.length && lines[i].trim()) {
|
||||
if (/^ {0,3}>/.test(lines[i])) body.push(lines[i].replace(/^ {0,3}> ?/, ''));
|
||||
// A wrapped line with no `>` still belongs to the quote it follows.
|
||||
else body.push(lines[i].trim());
|
||||
i++;
|
||||
}
|
||||
out.push('<blockquote>' + parseBlocks(body) + '</blockquote>');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (startsTable(lines, i)) {
|
||||
const header = splitRow(lines[i]);
|
||||
i += 2;
|
||||
const rows = [];
|
||||
while (i < lines.length && lines[i].trim() && lines[i].includes('|')) {
|
||||
rows.push(splitRow(lines[i]));
|
||||
i++;
|
||||
}
|
||||
out.push(tableToHtml(header, rows));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ITEM.test(line)) {
|
||||
const list = parseList(lines, i);
|
||||
out.push(list.html);
|
||||
i = list.next;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A paragraph, whose single newlines are line breaks rather than
|
||||
// paragraph breaks. That is how a note reads in a plain editor and how
|
||||
// Notes.app behaved, and it round-trips exactly — unlike the two
|
||||
// trailing spaces CommonMark wants, which no one can see.
|
||||
const paragraph = [];
|
||||
while (i < lines.length && lines[i].trim() && !startsBlock(lines, i)) {
|
||||
paragraph.push(lines[i].trim());
|
||||
i++;
|
||||
}
|
||||
out.push('<p>' + paragraph.map(inlineToHtml).join('<br>') + '</p>');
|
||||
}
|
||||
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
/** Everything that interrupts a paragraph. */
|
||||
function startsBlock(lines, index) {
|
||||
const line = lines[index];
|
||||
return (
|
||||
/^ {0,3}(```+|~~~+)/.test(line) ||
|
||||
/^ {0,3}#{1,6}\s/.test(line) ||
|
||||
/^ {0,3}>/.test(line) ||
|
||||
isRule(line) ||
|
||||
ITEM.test(line) ||
|
||||
startsTable(lines, index)
|
||||
);
|
||||
}
|
||||
|
||||
function isRule(line) {
|
||||
return /^ {0,3}([-*_])\s*(?:\1\s*){2,}$/.test(line);
|
||||
}
|
||||
|
||||
function startsTable(lines, index) {
|
||||
if (!lines[index].includes('|')) return false;
|
||||
const next = lines[index + 1];
|
||||
return Boolean(next) && /^\s*\|?(\s*:?-{1,}:?\s*\|)+\s*:?-*:?\s*\|?\s*$/.test(next) && next.includes('-');
|
||||
}
|
||||
|
||||
function splitRow(line) {
|
||||
let value = line.trim();
|
||||
if (value.startsWith('|')) value = value.slice(1);
|
||||
if (value.endsWith('|') && !value.endsWith('\\|')) value = value.slice(0, -1);
|
||||
// Split on pipes that are not escaped, then give the cells their pipes back.
|
||||
return value.split(/(?<!\\)\|/).map((cell) => cell.trim().replace(/\\\|/g, '|'));
|
||||
}
|
||||
|
||||
function tableToHtml(header, rows) {
|
||||
const width = Math.max(header.length, ...rows.map((row) => row.length), 1);
|
||||
const cells = (row, tag) => {
|
||||
let out = '';
|
||||
for (let i = 0; i < width; i++) {
|
||||
// A break in an empty cell: a `<td></td>` with nothing in it cannot be
|
||||
// clicked into, so a blank cell would be uneditable. It serializes
|
||||
// back to an empty cell.
|
||||
out += '<' + tag + '>' + (inlineToHtml(row[i] ?? '') || '<br>') + '</' + tag + '>';
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const body = rows.map((row) => '<tr>' + cells(row, 'td') + '</tr>').join('');
|
||||
return '<table><thead><tr>' + cells(header, 'th') + '</tr></thead><tbody>' + body + '</tbody></table>';
|
||||
}
|
||||
|
||||
/**
|
||||
* One list, and everything nested inside it.
|
||||
*
|
||||
* Continuation is by indentation: a line indented at least two columns past
|
||||
* the item's own marker belongs to that item, which is what makes nesting and
|
||||
* multi-paragraph items work without tracking marker widths through the
|
||||
* recursion. Indentation inside an item is relative, so the nested list parses
|
||||
* as a list of its own.
|
||||
*/
|
||||
function parseList(lines, start) {
|
||||
const first = ITEM.exec(lines[start]);
|
||||
const base = first[1].length;
|
||||
const ordered = /^\d/.test(first[2]);
|
||||
const startNumber = ordered ? Number.parseInt(first[2], 10) : 1;
|
||||
const items = [];
|
||||
let i = start;
|
||||
|
||||
while (i < lines.length) {
|
||||
const match = ITEM.exec(lines[i]);
|
||||
if (!match) break;
|
||||
// A shallower item ends this list; a deeper one is swallowed below as
|
||||
// part of the item above it, so reaching one here means the list is over.
|
||||
if (match[1].length !== base) break;
|
||||
if (/^\d/.test(match[2]) !== ordered) break;
|
||||
|
||||
const body = [match[3]];
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) {
|
||||
// A blank line keeps the item open only if something indented
|
||||
// follows it; otherwise the list ends here.
|
||||
const after = lines[i + 1];
|
||||
if (after && after.trim() && indentOf(after) >= base + CONTINUATION) {
|
||||
body.push('');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (indentOf(line) >= base + CONTINUATION) {
|
||||
body.push(line.slice(base + CONTINUATION));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ITEM.test(line) || startsBlock(lines, i)) break;
|
||||
// A wrapped line, typed without indentation.
|
||||
body.push(line.trim());
|
||||
i++;
|
||||
}
|
||||
items.push(body);
|
||||
}
|
||||
|
||||
const tag = ordered ? 'ol' : 'ul';
|
||||
const open = ordered && startNumber !== 1 ? '<ol start="' + startNumber + '">' : '<' + tag + '>';
|
||||
return { html: open + items.map(itemToHtml).join('') + '</' + tag + '>', next: i };
|
||||
}
|
||||
|
||||
function itemToHtml(body) {
|
||||
const task = /^\[([ xX])\]\s+([\s\S]*)$/.exec(body[0] ?? '');
|
||||
if (task) body = [task[2], ...body.slice(1)];
|
||||
|
||||
let inner = parseBlocks(body);
|
||||
// A tight item: its first paragraph is the item's own text, not a paragraph
|
||||
// inside it. Unwrapping only the first keeps multi-paragraph items intact.
|
||||
inner = inner.replace(/^<p>([\s\S]*?)<\/p>/, '$1');
|
||||
|
||||
if (!task) return '<li>' + inner + '</li>';
|
||||
const checked = task[1] !== ' ';
|
||||
return (
|
||||
'<li class="task"><input type="checkbox" contenteditable="false"' +
|
||||
(checked ? ' checked' : '') +
|
||||
'>' +
|
||||
inner +
|
||||
'</li>'
|
||||
);
|
||||
}
|
||||
|
||||
function indentOf(line) {
|
||||
return /^[ ]*/.exec(line)[0].length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline Markdown as HTML.
|
||||
*
|
||||
* Code spans are taken out first and put back last, so the stars and
|
||||
* underscores inside `**bold**` written as code stay literal.
|
||||
*/
|
||||
function inlineToHtml(text) {
|
||||
const literals = [];
|
||||
const park = (html) => {
|
||||
literals.push(html);
|
||||
return PLACEHOLDER + (literals.length - 1) + PLACEHOLDER;
|
||||
};
|
||||
|
||||
// Backslash escapes first, or `\*` would still be read as emphasis and a
|
||||
// backslashed backtick would still open a code span. Parked as literal
|
||||
// text, they take no further part in anything.
|
||||
let value = String(text).replace(/\\([\\`*_[\]#>~|+.()-])/g, (all, character) => park(escapeHtml(character)));
|
||||
|
||||
value = value.replace(/(`+)([\s\S]*?)\1/g, (all, fence, body) =>
|
||||
park('<code>' + escapeHtml(body.replace(/^ (.*) $/, '$1')) + '</code>'),
|
||||
);
|
||||
|
||||
value = escapeHtml(value);
|
||||
|
||||
// Links before emphasis: a label may contain either, and a URL may contain
|
||||
// underscores that are not emphasis. One level of balanced parentheses is
|
||||
// allowed in the target, because real links have them —
|
||||
// de.wikipedia.org/wiki/Erörterung_(Textsorte).
|
||||
value = value.replace(/\[([^\]]*)\]\(((?:[^()\s]|\([^()\s]*\))*)\)/g, (all, label, href) => {
|
||||
const safe = safeUrl(href);
|
||||
if (!safe) return label;
|
||||
return '<a href="' + safe + '">' + (label || safe) + '</a>';
|
||||
});
|
||||
|
||||
value = value.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, '<strong>$2</strong>');
|
||||
value = value.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
|
||||
// A single marker, not part of a double one, and not mid-word for `_` —
|
||||
// otherwise snake_case_names turn into emphasis.
|
||||
value = value.replace(/(?<!\*)\*(?!\*)(?=\S)([\s\S]*?\S)\*(?!\*)/g, '<em>$1</em>');
|
||||
value = value.replace(/(?<![\w_])_(?!_)(?=\S)([\s\S]*?\S)_(?![\w_])/g, '<em>$1</em>');
|
||||
|
||||
return value.replace(PLACEHOLDERS, (all, index) => literals[Number(index)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A link target, or nothing.
|
||||
*
|
||||
* The editor's content comes from the user's own notes, but a note can be
|
||||
* written by anything — an import, a paste from a web page — so a `javascript:`
|
||||
* url is refused rather than rendered into a document a finger will tap.
|
||||
*/
|
||||
function safeUrl(href) {
|
||||
const value = href.trim();
|
||||
if (!value) return '';
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(value) && !/^(https?|mailto|tel):/i.test(value)) return '';
|
||||
return escapeHtml(value).replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// --- HTML → Markdown -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* What the editor holds, as the Markdown that will be written to the file.
|
||||
*
|
||||
* Deliberately tolerant: browsers put their own tags into a contenteditable
|
||||
* element (`<div>` for a line, `<span style="font-weight: bold">` after a
|
||||
* paste, `<font>` on older engines), and none of that may cost a word. An
|
||||
* element with no meaning here serializes its children.
|
||||
*
|
||||
* `root` needs only the read-only parts of the DOM — `nodeType`, `nodeName`,
|
||||
* `childNodes`, `textContent` and `getAttribute` — so the same function runs
|
||||
* against a plain tree in the tests.
|
||||
*/
|
||||
export function markdownFromDom(root) {
|
||||
return serializeBlocks(root).replace(/[ \t]+$/gm, '').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
const BLOCK_TAGS = new Set([
|
||||
'P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
|
||||
'UL', 'OL', 'BLOCKQUOTE', 'PRE', 'HR', 'TABLE', 'SECTION', 'ARTICLE', 'FIGURE',
|
||||
]);
|
||||
|
||||
function serializeBlocks(node) {
|
||||
const out = [];
|
||||
let inline = [];
|
||||
|
||||
const flush = () => {
|
||||
if (inline.length === 0) return;
|
||||
const text = paragraph(inlineFrom(inline));
|
||||
if (text) out.push(text);
|
||||
inline = [];
|
||||
};
|
||||
|
||||
for (const child of children(node)) {
|
||||
if (child.nodeType === 1 && BLOCK_TAGS.has(child.nodeName)) {
|
||||
flush();
|
||||
const block = serializeBlock(child);
|
||||
if (block) out.push(block);
|
||||
} else {
|
||||
inline.push(child);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
|
||||
return out.join('\n\n');
|
||||
}
|
||||
|
||||
function serializeBlock(element) {
|
||||
switch (element.nodeName) {
|
||||
case 'H1':
|
||||
case 'H2':
|
||||
case 'H3':
|
||||
case 'H4':
|
||||
case 'H5':
|
||||
case 'H6': {
|
||||
const text = inlineFrom(children(element)).replace(/\n+/g, ' ').trim();
|
||||
if (!text) return '';
|
||||
return '#'.repeat(Number(element.nodeName[1])) + ' ' + text;
|
||||
}
|
||||
case 'HR':
|
||||
return '---';
|
||||
case 'PRE': {
|
||||
const body = element.textContent.replace(/\n$/, '');
|
||||
const language = languageOf(element);
|
||||
// A fence longer than any run of backticks inside, or a note about
|
||||
// Markdown closes its own code block.
|
||||
const longest = Math.max(2, ...[...body.matchAll(/`+/g)].map((match) => match[0].length));
|
||||
const fence = '`'.repeat(longest + 1);
|
||||
return fence + language + '\n' + body + '\n' + fence;
|
||||
}
|
||||
case 'BLOCKQUOTE': {
|
||||
const inner = serializeBlocks(element).trim();
|
||||
if (!inner) return '';
|
||||
return inner.split('\n').map((line) => (line ? '> ' + line : '>')).join('\n');
|
||||
}
|
||||
case 'UL':
|
||||
case 'OL':
|
||||
return serializeList(element);
|
||||
case 'TABLE':
|
||||
return serializeTable(element);
|
||||
case 'DIV':
|
||||
case 'SECTION':
|
||||
case 'ARTICLE':
|
||||
case 'FIGURE':
|
||||
// A browser's line wrapper, or a real container. Both are handled by
|
||||
// asking what is inside.
|
||||
return hasBlockChild(element) ? serializeBlocks(element) : paragraph(inlineFrom(children(element)));
|
||||
default:
|
||||
return paragraph(inlineFrom(children(element)));
|
||||
}
|
||||
}
|
||||
|
||||
function serializeList(list, depth = 0) {
|
||||
const ordered = list.nodeName === 'OL';
|
||||
const start = Number.parseInt(list.getAttribute('start') ?? '', 10);
|
||||
let number = Number.isFinite(start) && start > 0 ? start : 1;
|
||||
const out = [];
|
||||
|
||||
// Two columns per level, matching what the parser takes back apart.
|
||||
const indent = ' '.repeat(CONTINUATION);
|
||||
const shift = (block) => block.split('\n').map((line) => (line ? indent + line : '')).join('\n');
|
||||
|
||||
for (const item of children(list)) {
|
||||
if (item.nodeType !== 1) continue;
|
||||
if (item.nodeName === 'UL' || item.nodeName === 'OL') {
|
||||
// A list as a *sibling* of the items rather than inside one. Several
|
||||
// engines produce this when Tab indents a bullet, and skipping it
|
||||
// would silently drop everything the person nested.
|
||||
const nested = shift(serializeList(item, depth + 1));
|
||||
if (out.length > 0) out[out.length - 1] += '\n' + nested;
|
||||
else out.push(nested);
|
||||
continue;
|
||||
}
|
||||
if (item.nodeName !== 'LI') continue;
|
||||
|
||||
const checkbox = firstCheckbox(item);
|
||||
const marker = ordered ? number++ + '.' : '-';
|
||||
const box = checkbox ? (checkbox.getAttribute('checked') === null ? '[ ] ' : '[x] ') : '';
|
||||
|
||||
// The item's own text, then whatever blocks hang under it.
|
||||
const leading = [];
|
||||
const blocks = [];
|
||||
for (const child of children(item)) {
|
||||
if (child === checkbox) continue;
|
||||
if (child.nodeType === 1 && BLOCK_TAGS.has(child.nodeName)) blocks.push(child);
|
||||
else if (blocks.length === 0) leading.push(child);
|
||||
// Inline content after a nested list is rare and reads as part of it.
|
||||
else blocks.push(child);
|
||||
}
|
||||
|
||||
// An item whose text the browser wrapped in a div or a p: that is the
|
||||
// item's own line, not a block underneath it.
|
||||
if (leading.length === 0 && blocks.length > 0 && (blocks[0].nodeName === 'DIV' || blocks[0].nodeName === 'P')) {
|
||||
if (!hasBlockChild(blocks[0])) leading.push(...children(blocks.shift()));
|
||||
}
|
||||
|
||||
const head = paragraph(inlineFrom(leading));
|
||||
const rest = blocks
|
||||
.map((child) =>
|
||||
child.nodeType === 1 && (child.nodeName === 'UL' || child.nodeName === 'OL')
|
||||
? serializeList(child, depth + 1)
|
||||
: serializeBlock(child),
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
const first = marker + ' ' + box + head.split('\n').join('\n' + indent);
|
||||
out.push([first, ...rest.map(shift)].join('\n'));
|
||||
}
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function serializeTable(table) {
|
||||
const rows = [];
|
||||
const walk = (node) => {
|
||||
for (const child of children(node)) {
|
||||
if (child.nodeType !== 1) continue;
|
||||
if (child.nodeName === 'TR') rows.push(child);
|
||||
else walk(child);
|
||||
}
|
||||
};
|
||||
walk(table);
|
||||
if (rows.length === 0) return '';
|
||||
|
||||
const cells = rows.map((row) =>
|
||||
children(row)
|
||||
.filter((cell) => cell.nodeType === 1 && (cell.nodeName === 'TD' || cell.nodeName === 'TH'))
|
||||
.map((cell) => inlineFrom(children(cell)).replace(/\n+/g, ' ').replace(/\|/g, '\\|').trim()),
|
||||
);
|
||||
const width = Math.max(...cells.map((row) => row.length));
|
||||
const line = (row) => '| ' + Array.from({ length: width }, (_, i) => row[i] ?? '').join(' | ') + ' |';
|
||||
|
||||
// A header row is required by the syntax: a table whose first row is data
|
||||
// would otherwise lose that row entirely.
|
||||
return [line(cells[0]), '|' + ' --- |'.repeat(width), ...cells.slice(1).map(line)].join('\n');
|
||||
}
|
||||
|
||||
function inlineFrom(nodes) {
|
||||
return nodes.map(inlineNode).join('');
|
||||
}
|
||||
|
||||
function inlineNode(node) {
|
||||
if (node.nodeType === 3) return escapeText(node.textContent);
|
||||
if (node.nodeType !== 1) return '';
|
||||
|
||||
switch (node.nodeName) {
|
||||
case 'BR':
|
||||
return '\n';
|
||||
case 'IMG':
|
||||
// No note here has an image; one arriving by paste says so rather
|
||||
// than vanishing.
|
||||
return node.getAttribute('alt') ? '[' + escapeText(node.getAttribute('alt')) + ']' : '';
|
||||
case 'INPUT':
|
||||
// Only ever a task checkbox, and `serializeList` has already read it.
|
||||
return '';
|
||||
case 'CODE': {
|
||||
const body = node.textContent;
|
||||
if (!body) return '';
|
||||
const longest = Math.max(0, ...[...body.matchAll(/`+/g)].map((match) => match[0].length));
|
||||
const fence = '`'.repeat(longest + 1);
|
||||
const pad = body.startsWith('`') || body.endsWith('`') ? ' ' : '';
|
||||
return fence + pad + body + pad + fence;
|
||||
}
|
||||
case 'A': {
|
||||
const label = inlineFrom(children(node));
|
||||
const href = (node.getAttribute('href') ?? '').trim();
|
||||
if (!href) return label;
|
||||
if (!label.trim()) return href;
|
||||
return '[' + label + '](' + href + ')';
|
||||
}
|
||||
case 'STRONG':
|
||||
case 'B':
|
||||
return emphasise(inlineFrom(children(node)), '**');
|
||||
case 'EM':
|
||||
case 'I':
|
||||
return emphasise(inlineFrom(children(node)), '_');
|
||||
case 'DEL':
|
||||
case 'S':
|
||||
case 'STRIKE':
|
||||
return emphasise(inlineFrom(children(node)), '~~');
|
||||
case 'SPAN':
|
||||
case 'FONT': {
|
||||
// What a paste leaves behind. The tag says nothing; the style might.
|
||||
const style = node.getAttribute('style') ?? '';
|
||||
const inner = inlineFrom(children(node));
|
||||
if (/font-weight:\s*(bold|[6-9]00)/i.test(style)) return emphasise(inner, '**');
|
||||
if (/font-style:\s*italic/i.test(style)) return emphasise(inner, '_');
|
||||
return inner;
|
||||
}
|
||||
default:
|
||||
return inlineFrom(children(node));
|
||||
}
|
||||
}
|
||||
|
||||
/** Markers hug their text: `** bold **` is four literal stars, not emphasis. */
|
||||
function emphasise(inner, marker) {
|
||||
const parts = /^(\s*)([\s\S]*?)(\s*)$/.exec(inner);
|
||||
if (!parts[2]) return inner;
|
||||
// Already carrying the same marker (nested `<b><b>`, or a paste): once is enough.
|
||||
if (parts[2].startsWith(marker) && parts[2].endsWith(marker)) return inner;
|
||||
return parts[1] + marker + parts[2] + marker + parts[3];
|
||||
}
|
||||
|
||||
/**
|
||||
* A run of inline content as one paragraph.
|
||||
*
|
||||
* Line starts are escaped here rather than in `escapeText`, because whether a
|
||||
* `-` opens a list depends on where in the line it sits.
|
||||
*/
|
||||
function paragraph(text) {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) =>
|
||||
line
|
||||
.replace(/^(\s*)([#>]|[-*+](?=\s))/, '$1\\$2')
|
||||
// The backslash goes before the dot, never before the digit: a
|
||||
// backslash in front of anything but punctuation is a literal
|
||||
// backslash, and `\1.` would be written into the file as it looks.
|
||||
.replace(/^(\s*\d{1,9})([.)](?=\s))/, '$1\\$2'),
|
||||
)
|
||||
.join('\n')
|
||||
.replace(/^\n+|\n+$/g, '');
|
||||
}
|
||||
|
||||
function escapeText(value) {
|
||||
return String(value)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/([`*[\]])/g, '\\$1')
|
||||
// Only where it could be read as emphasis: `snake_case` stays readable.
|
||||
.replace(/(^|[^\w_])_/g, '$1\\_')
|
||||
.replace(/_($|[^\w_])/g, '\\_$1')
|
||||
.replace(/~~/g, '\\~\\~')
|
||||
// A lone `<` only matters when it could open a tag.
|
||||
.replace(/<(?=[a-zA-Z/!])/g, '\\<');
|
||||
}
|
||||
|
||||
function languageOf(pre) {
|
||||
for (const child of children(pre)) {
|
||||
if (child.nodeType === 1 && child.nodeName === 'CODE') {
|
||||
const match = /language-([A-Za-z0-9_+#-]+)/.exec(child.getAttribute('class') ?? '');
|
||||
if (match) return match[1];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function firstCheckbox(item) {
|
||||
for (const child of children(item)) {
|
||||
if (child.nodeType === 1 && child.nodeName === 'INPUT' && child.getAttribute('type') === 'checkbox') return child;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasBlockChild(element) {
|
||||
return children(element).some((child) => child.nodeType === 1 && BLOCK_TAGS.has(child.nodeName));
|
||||
}
|
||||
|
||||
function children(node) {
|
||||
return Array.prototype.slice.call(node.childNodes ?? []);
|
||||
}
|
||||
@@ -9,13 +9,35 @@ import type { NextFunction, Request, Response } from 'express';
|
||||
* the token is the only thing between a stranger and the account's data.
|
||||
* Comparison is constant-time, and a miss returns a bare 401 with a
|
||||
* `WWW-Authenticate` challenge and no detail about why.
|
||||
*
|
||||
* A route can accept more than one token: `/mcp` also takes the connector
|
||||
* token claude.ai stores, which `/api` refuses.
|
||||
*
|
||||
* `alsoAccept` is the other kind of caller: a person logged into the web app,
|
||||
* carrying a session cookie rather than a token. `/api` takes it because the
|
||||
* app is built on `/api` and a session *is* the user; `/mcp` does not, because
|
||||
* nothing in a browser speaks MCP and a surface not needed is a surface not
|
||||
* offered.
|
||||
*/
|
||||
export function bearerAuth(expected: string) {
|
||||
const expectedBytes = Buffer.from(expected, 'utf8');
|
||||
export function bearerAuth(accepted: string | string[], alsoAccept?: (req: Request) => boolean) {
|
||||
const expected = (Array.isArray(accepted) ? accepted : [accepted]).map((token) => Buffer.from(token, 'utf8'));
|
||||
|
||||
return function authenticate(req: Request, res: Response, next: NextFunction): void {
|
||||
const presented = extractToken(req.get('authorization'), req.get('x-api-key'));
|
||||
if (presented === undefined || !constantTimeEquals(Buffer.from(presented, 'utf8'), expectedBytes)) {
|
||||
// A logged-in browser instead of a token. Checked first because the app's
|
||||
// own fetches carry no Authorization header at all, and running them
|
||||
// through the token comparison would only waste it.
|
||||
if (alsoAccept?.(req)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const presented = extractToken(req.get('authorization'), req.get('x-api-key') ?? req.get('x-auth-token'));
|
||||
// Every token is compared even after a match, so the timing does not
|
||||
// tell which one was presented.
|
||||
const matched =
|
||||
presented !== undefined &&
|
||||
expected.map((token) => constantTimeEquals(Buffer.from(presented, 'utf8'), token)).includes(true);
|
||||
if (!matched) {
|
||||
res.setHeader('WWW-Authenticate', 'Bearer realm="schulcloud-mcp"');
|
||||
res.status(401).json({
|
||||
jsonrpc: '2.0',
|
||||
@@ -28,12 +50,38 @@ export function bearerAuth(expected: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate for `/:secret/mcp`, the header-free way in.
|
||||
*
|
||||
* A wrong secret answers exactly like any other unknown path, so guessing
|
||||
* learns nothing — not even that the route exists. The comparison is
|
||||
* constant-time for the same reason as the bearer check's.
|
||||
*/
|
||||
export function pathSecret(expected: string) {
|
||||
const expectedBytes = Buffer.from(expected, 'utf8');
|
||||
|
||||
return function checkPathSecret(req: Request, res: Response, next: NextFunction): void {
|
||||
const presented = req.params.secret;
|
||||
if (typeof presented !== 'string' || !constantTimeEquals(Buffer.from(presented, 'utf8'), expectedBytes)) {
|
||||
res.status(404).json({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
function extractToken(authorization: string | undefined, apiKey: string | undefined): string | undefined {
|
||||
if (authorization) {
|
||||
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
|
||||
const value = authorization.trim();
|
||||
const match = /^Bearer\s+(.+)$/i.exec(value);
|
||||
if (match?.[1]) return match[1].trim();
|
||||
// claude.ai sends a request header exactly as typed, so a token entered
|
||||
// without "Bearer " arrives bare — its own docs warn most servers reject
|
||||
// that. A bare credential is still the whole credential; one with another
|
||||
// scheme ("Basic …") has a space in it and is not taken for one.
|
||||
if (value && !/\s/.test(value)) return value;
|
||||
}
|
||||
// Some connector UIs only offer a custom header rather than Authorization.
|
||||
// Connector UIs also offer X-Api-Key and X-Auth-Token instead of Authorization.
|
||||
return apiKey?.trim() || undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import type { Config } from '../config.ts';
|
||||
import { createServer } from '../mcp/server.ts';
|
||||
import type { Services } from '../services.ts';
|
||||
import { createApiRouter } from './api.ts';
|
||||
import { bearerAuth } from './auth.ts';
|
||||
import { createAppRouter } from './app-page.ts';
|
||||
import { bearerAuth, pathSecret } from './auth.ts';
|
||||
import { tokenPage, tokenScript } from './token-page.ts';
|
||||
|
||||
/**
|
||||
* Streamable-HTTP front end, for use as a remote MCP connector.
|
||||
@@ -56,12 +58,21 @@ export function createHttpApp(config: Config, services?: Services): express.Expr
|
||||
res.json({ status: 'ok', sessions: sessions.size, index: services?.store ? 'on' : 'off' });
|
||||
});
|
||||
|
||||
// One token guards both surfaces: the MCP endpoint and the CLI's file/manifest
|
||||
// API. Splitting them was considered and rejected as unnecessary ceremony for
|
||||
// a single-user deployment.
|
||||
// MCP_AUTH_TOKEN opens both surfaces: the MCP endpoint and the CLI's API. The
|
||||
// connector token opens /mcp alone. claude.ai stores it as a request header,
|
||||
// and a credential held by a third party should reach the read-only tools,
|
||||
// not /api, which can replace the Schulcloud token and stream the file mirror.
|
||||
// The web app, when a password is configured. Mounted before the token gate
|
||||
// so its login screen is reachable without one — it is the thing that issues
|
||||
// the session everything else then accepts.
|
||||
const appSurface = services ? createAppRouter(config) : undefined;
|
||||
if (appSurface) app.use('/app', appSurface.router);
|
||||
|
||||
const loggedIn = appSurface ? (req: Request) => appSurface.auth.verify(req.get('cookie')) : undefined;
|
||||
|
||||
if (config.authToken) {
|
||||
app.use(MCP_PATH, bearerAuth(config.authToken));
|
||||
app.use(API_PATH, bearerAuth(config.authToken));
|
||||
app.use(MCP_PATH, bearerAuth(config.connectorToken ? [config.authToken, config.connectorToken] : config.authToken));
|
||||
app.use(API_PATH, bearerAuth(config.authToken, loggedIn));
|
||||
} else {
|
||||
console.warn(
|
||||
'[schulcloud-mcp] MCP_AUTH_TOKEN is not set — the endpoint is UNAUTHENTICATED. ' +
|
||||
@@ -71,11 +82,13 @@ export function createHttpApp(config: Config, services?: Services): express.Expr
|
||||
|
||||
if (services) {
|
||||
app.use(API_PATH, createApiRouter(services));
|
||||
// The page to paste a fresh Schulcloud token into. It holds no secret: what
|
||||
// it sends goes to /api/token, behind the bearer check above.
|
||||
app.get('/token', tokenPage);
|
||||
app.get('/token.js', tokenScript);
|
||||
}
|
||||
|
||||
app.use(MCP_PATH, express.json({ limit: '4mb' }));
|
||||
|
||||
app.post(MCP_PATH, async (req: Request, res: Response) => {
|
||||
const handlePost = async (req: Request, res: Response): Promise<void> => {
|
||||
const sessionId = req.get('mcp-session-id');
|
||||
|
||||
try {
|
||||
@@ -124,7 +137,9 @@ export function createHttpApp(config: Config, services?: Services): express.Expr
|
||||
console.error('[schulcloud-mcp] POST failed:', error);
|
||||
if (!res.headersSent) res.status(500).json(rpcError(-32603, 'Internal server error'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
app.post(MCP_PATH, express.json({ limit: '4mb' }), handlePost);
|
||||
|
||||
// GET opens the server→client SSE stream; DELETE ends the session.
|
||||
const bySession = async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -146,6 +161,19 @@ export function createHttpApp(config: Config, services?: Services): express.Expr
|
||||
app.get(MCP_PATH, bySession);
|
||||
app.delete(MCP_PATH, bySession);
|
||||
|
||||
// The same endpoint without a bearer token, for clients that cannot send one:
|
||||
// claude.ai's connector dialog takes only a URL. The path is the credential
|
||||
// here, so nothing in this server logs request paths — keep it that way — and
|
||||
// the Caddy snippet redacts it from the access log. A stopgap until the
|
||||
// endpoint speaks OAuth, which is what connectors are meant to use.
|
||||
if (config.mcpPathSecret) {
|
||||
const secretMcpPath = '/:secret/mcp';
|
||||
const gate = pathSecret(config.mcpPathSecret);
|
||||
app.post(secretMcpPath, gate, express.json({ limit: '4mb' }), handlePost);
|
||||
app.get(secretMcpPath, gate, bySession);
|
||||
app.delete(secretMcpPath, gate, bySession);
|
||||
}
|
||||
|
||||
app.use((_req, res) => res.status(404).json({ error: 'not_found' }));
|
||||
|
||||
return app;
|
||||
|
||||
142
src/http/token-page.ts
Normal file
142
src/http/token-page.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* `/token`: a page to paste a fresh Schulcloud token into, for when a terminal
|
||||
* is not at hand. `schulcloud token set` does the same from the CLI.
|
||||
*
|
||||
* The page carries no secret and needs no login of its own. It sends what is
|
||||
* typed into it to `PUT /api/token` with the server access token as a bearer,
|
||||
* so it is exactly as protected as the API — and the server checks the pasted
|
||||
* token against Schulcloud before using it.
|
||||
*
|
||||
* The cookie is HttpOnly, so no script on the Schulcloud page can read it and a
|
||||
* one-click bookmarklet is impossible; copying it out of DevTools is the step
|
||||
* that remains.
|
||||
*/
|
||||
|
||||
const SECURITY_HEADERS = {
|
||||
// The page's script is a separate file only because this policy forbids
|
||||
// inline script; nothing it loads comes from anywhere else.
|
||||
'Content-Security-Policy':
|
||||
"default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; " +
|
||||
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Cache-Control': 'no-store',
|
||||
};
|
||||
|
||||
const PAGE = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Schulcloud token</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
||||
body { margin: 0; padding: 2rem 1rem; }
|
||||
main { max-width: 34rem; margin: 0 auto; }
|
||||
h1 { font-size: 1.4rem; }
|
||||
ol { padding-left: 1.2rem; line-height: 1.5; }
|
||||
label { display: block; margin: 1rem 0 0.25rem; font-weight: 600; }
|
||||
input { box-sizing: border-box; width: 100%; padding: 0.5rem; font: inherit; }
|
||||
.actions { display: flex; gap: 0.5rem; margin-top: 1rem; flex-wrap: wrap; }
|
||||
button { padding: 0.5rem 1rem; font: inherit; cursor: pointer; }
|
||||
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
#result { margin-top: 1rem; min-height: 1.5em; }
|
||||
.ok { color: #1a7f37; }
|
||||
.error { color: #cf222e; }
|
||||
@media (prefers-color-scheme: dark) { .ok { color: #3fb950; } .error { color: #f85149; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Replace the Schulcloud token</h1>
|
||||
<ol>
|
||||
<li>Open a private window and log in to Schulcloud.</li>
|
||||
<li>DevTools → Application (Firefox: Storage) → Cookies → the cookie named <code>jwt</code>: copy its value.</li>
|
||||
<li>Paste it below and press <em>Replace</em>. The server checks it with Schulcloud first.</li>
|
||||
<li><strong>Close the private window.</strong> Left open, it logs the token out about two hours after login.</li>
|
||||
</ol>
|
||||
<form id="form">
|
||||
<input class="visually-hidden" type="text" name="username" value="schulcloud-mcp" autocomplete="username" tabindex="-1" aria-hidden="true">
|
||||
<label for="access">Server access token (MCP_AUTH_TOKEN)</label>
|
||||
<input id="access" name="password" type="password" autocomplete="current-password" required>
|
||||
<label for="jwt">jwt cookie</label>
|
||||
<input id="jwt" type="password" autocomplete="off" spellcheck="false">
|
||||
<div class="actions">
|
||||
<button type="submit">Replace</button>
|
||||
<button type="button" id="check">Check current token</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="result" role="status" aria-live="polite"></p>
|
||||
</main>
|
||||
<script src="token.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const SCRIPT = `'use strict';
|
||||
const form = document.getElementById('form');
|
||||
const access = document.getElementById('access');
|
||||
const jwt = document.getElementById('jwt');
|
||||
const result = document.getElementById('result');
|
||||
|
||||
function show(text, ok) {
|
||||
result.textContent = text;
|
||||
result.className = ok ? 'ok' : 'error';
|
||||
}
|
||||
|
||||
function describe(status) {
|
||||
const expiry = status.expiresAt
|
||||
? 'expires ' + status.expiresAt.slice(0, 10) + ' (' + status.daysLeft + ' days left)'
|
||||
: 'expiry unknown';
|
||||
const keepalive = status.keepalive;
|
||||
const session = !keepalive ? '' : keepalive.running ? 'session alive' : 'session ended — replace the token';
|
||||
return [expiry, session].filter(Boolean).join('; ');
|
||||
}
|
||||
|
||||
async function call(method, body) {
|
||||
const headers = { authorization: 'Bearer ' + access.value.trim() };
|
||||
if (body) headers['content-type'] = 'application/json';
|
||||
const response = await fetch('api/token', {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
cache: 'no-store',
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (response.status === 401) throw new Error('The server access token is wrong.');
|
||||
if (!response.ok) throw new Error(data.message || 'HTTP ' + response.status);
|
||||
return data;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (!jwt.value.trim()) return show('Paste the jwt cookie first.', false);
|
||||
show('Checking it with Schulcloud…', true);
|
||||
try {
|
||||
const data = await call('PUT', { jwt: jwt.value });
|
||||
jwt.value = '';
|
||||
const saved = data.changed && !data.persisted ? ' Not saved on the server: a restart falls back to TSC_JWT_COOKIE.' : '';
|
||||
show((data.changed ? 'Replaced — ' : 'Already in use — ') + describe(data) + '.' + saved + ' Now close the private window.', true);
|
||||
} catch (error) {
|
||||
show(error.message, false);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('check').addEventListener('click', async () => {
|
||||
try {
|
||||
show('Current token ' + describe(await call('GET')) + '.', true);
|
||||
} catch (error) {
|
||||
show(error.message, false);
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
export function tokenPage(_req: Request, res: Response): void {
|
||||
res.set(SECURITY_HEADERS).type('html').send(PAGE);
|
||||
}
|
||||
|
||||
export function tokenScript(_req: Request, res: Response): void {
|
||||
res.set(SECURITY_HEADERS).type('application/javascript').send(SCRIPT);
|
||||
}
|
||||
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.' });
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import type { Config } from '../config.ts';
|
||||
import type { SchulcloudClient } from '../core/client.ts';
|
||||
import type { DownloadedFile, SchulcloudClient } from '../core/client.ts';
|
||||
import { crawl, forEachLimited, type Snapshot } from '../core/crawl.ts';
|
||||
import { extractContent, formatBytes } from '../core/extract.ts';
|
||||
import { mirrorPath, resolveWithin } from '../core/paths.ts';
|
||||
import { addDays, schoolToday } from '../core/dates.ts';
|
||||
import { collectLessonLog, type LessonLogEntry } from '../core/untis-history.ts';
|
||||
import type { UntisClient } from '../core/untis.ts';
|
||||
import type { Store } from '../store/store.ts';
|
||||
|
||||
/**
|
||||
@@ -23,6 +26,12 @@ export interface IndexResult {
|
||||
crawlId: number;
|
||||
scope: string;
|
||||
courses: number;
|
||||
/** Rooms walked. Zero is normal — many accounts are in none. */
|
||||
rooms: number;
|
||||
/** The user's own notes picked up from NOTES_DIR. */
|
||||
notes: number;
|
||||
/** Class-register entries read from WebUntis. Zero without a key, or without a register. */
|
||||
lessons: number;
|
||||
files: number;
|
||||
mirrored: number;
|
||||
extracted: number;
|
||||
@@ -46,6 +55,8 @@ export class Indexer {
|
||||
private readonly store: Store;
|
||||
private readonly config: Config;
|
||||
private readonly minIntervalMs: number;
|
||||
/** WebUntis, when configured: the class register is indexed alongside Schulcloud. */
|
||||
private readonly untis: UntisClient | undefined;
|
||||
|
||||
private inFlight = new Map<string, Promise<IndexResult>>();
|
||||
private startedAt: Date | undefined;
|
||||
@@ -54,11 +65,17 @@ export class Indexer {
|
||||
private lastResult: IndexResult | undefined;
|
||||
private lastError: string | undefined;
|
||||
|
||||
constructor(client: SchulcloudClient, store: Store, config: Config, minIntervalMs = 60_000) {
|
||||
constructor(
|
||||
client: SchulcloudClient,
|
||||
store: Store,
|
||||
config: Config,
|
||||
options: { untis?: UntisClient; minIntervalMs?: number } = {},
|
||||
) {
|
||||
this.client = client;
|
||||
this.store = store;
|
||||
this.config = config;
|
||||
this.minIntervalMs = minIntervalMs;
|
||||
this.untis = options.untis;
|
||||
this.minIntervalMs = options.minIntervalMs ?? 60_000;
|
||||
}
|
||||
|
||||
status(): IndexerStatus {
|
||||
@@ -79,16 +96,30 @@ export class Indexer {
|
||||
* while a run is in progress join it rather than starting a second.
|
||||
*/
|
||||
async refresh(scope: string, options: { force?: boolean } = {}): Promise<IndexResult> {
|
||||
return this.start(scope, options).run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts (or joins) a re-crawl without waiting for it.
|
||||
*
|
||||
* Not async on purpose: the rate-limit refusal throws synchronously, so a
|
||||
* caller that answers immediately — the CLI's refresh route — can still
|
||||
* report it. Waiting is the caller's choice, and holding one request open for
|
||||
* a whole crawl does not survive a first crawl of the file manager: it
|
||||
* downloads every course file once, and Node's fetch gives up on a response
|
||||
* whose headers have not arrived after five minutes.
|
||||
*/
|
||||
start(scope: string, options: { force?: boolean } = {}): { run: Promise<IndexResult>; joined: boolean } {
|
||||
// A full crawl covers every course, so a per-course request can ride along.
|
||||
const existing = this.inFlight.get('full') ?? this.inFlight.get(scope);
|
||||
if (existing) return existing.then((result) => ({ ...result, joined: true }));
|
||||
if (existing) return { run: existing.then((result) => ({ ...result, joined: true })), joined: true };
|
||||
|
||||
const since = Date.now() - (this.lastFinishedAt.get(scope) ?? 0);
|
||||
if (!options.force && since < this.minIntervalMs) {
|
||||
const wait = Math.ceil((this.minIntervalMs - since) / 1000);
|
||||
throw new Error(
|
||||
`${scope === 'full' ? 'A full re-crawl' : `Course ${scope}`} was refreshed ${Math.round(since / 1000)}s ago. ` +
|
||||
`Wait ${wait}s, or pass force to override — a full crawl is ~270 requests against Schulcloud.`,
|
||||
`Wait ${wait}s, or pass force to override — a full crawl is several hundred requests against Schulcloud.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,20 +132,30 @@ export class Indexer {
|
||||
this.inFlight.set(scope, run);
|
||||
this.runningScope = scope;
|
||||
this.startedAt = new Date();
|
||||
return run;
|
||||
return { run, joined: false };
|
||||
}
|
||||
|
||||
private async run(scope: string): Promise<IndexResult> {
|
||||
const began = Date.now();
|
||||
try {
|
||||
const schoolId = (await this.client.me()).school.id;
|
||||
const me = await this.client.me();
|
||||
const snapshot: Snapshot = await crawl(this.client, {
|
||||
schoolId,
|
||||
schoolId: me.school.id,
|
||||
userId: me.user.id,
|
||||
courseIds: scope === 'full' ? undefined : [scope],
|
||||
includeLessonContents: true,
|
||||
includeFiles: true,
|
||||
includePersonalFiles: this.config.indexPersonalFiles,
|
||||
includeFileManager: this.config.indexFileManager,
|
||||
config: this.config,
|
||||
// Notes and the class register belong to the whole account, not to
|
||||
// one course, so a per-course refresh leaves them alone and the
|
||||
// store's carry-forward keeps the previous generation's rows.
|
||||
...(scope === 'full' && this.config.notesDir ? { notesDir: this.config.notesDir } : {}),
|
||||
});
|
||||
|
||||
if (scope === 'full') snapshot.lessonLog = await this.readLessonLog();
|
||||
|
||||
const crawlId = await this.store.saveSnapshot(snapshot, scope);
|
||||
const { mirrored, extracted, skipped } = await this.ingestFiles(snapshot);
|
||||
|
||||
@@ -122,6 +163,9 @@ export class Indexer {
|
||||
crawlId,
|
||||
scope,
|
||||
courses: snapshot.courses.length,
|
||||
rooms: snapshot.rooms.length,
|
||||
notes: snapshot.notes.length,
|
||||
lessons: snapshot.lessonLog.length,
|
||||
files: snapshot.files.length,
|
||||
mirrored,
|
||||
extracted,
|
||||
@@ -138,6 +182,34 @@ export class Indexer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The WebUntis class register for the configured window.
|
||||
*
|
||||
* Never fatal. WebUntis is a second upstream with its own key, its own
|
||||
* clock requirement and its own outages, and a Schulcloud crawl that failed
|
||||
* because the timetable server was down would be the wrong trade entirely.
|
||||
*/
|
||||
private async readLessonLog(): Promise<LessonLogEntry[]> {
|
||||
const days = this.config.untisHistoryDays;
|
||||
if (!this.untis || days <= 0) return [];
|
||||
const today = schoolToday();
|
||||
try {
|
||||
const log = await collectLessonLog(this.untis, { from: addDays(today, -days), to: today });
|
||||
if (log.failures.length > 0) {
|
||||
console.warn(
|
||||
`[schulcloud-mcp] class register: ${log.failures.length} lesson series could not be read; ` +
|
||||
'their topics are missing from the index.',
|
||||
);
|
||||
}
|
||||
return log.entries;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[schulcloud-mcp] class register not indexed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads, mirrors and extracts every file the index has no text for.
|
||||
*
|
||||
@@ -185,8 +257,29 @@ export class Indexer {
|
||||
return;
|
||||
}
|
||||
|
||||
// Downloading and extracting fail for different reasons, and only the
|
||||
// first is worth trying again: a timeout or a 503 is the network's, a
|
||||
// PDF the parser rejects will reject again. Recording both the same way
|
||||
// used to make every transient failure permanent.
|
||||
let downloaded: DownloadedFile;
|
||||
try {
|
||||
// The file manager's ids mean nothing to files-storage; route by store.
|
||||
downloaded =
|
||||
file.source === 'file-manager'
|
||||
? await this.client.downloadFileManagerFile(file.record.id, file.record.name)
|
||||
: await this.client.downloadFile(file.record);
|
||||
} catch (error) {
|
||||
await this.store.recordFileText({
|
||||
fileId: entry.fileId, name: entry.name, mimeType: entry.mimeType, size: entry.size,
|
||||
content: null,
|
||||
note: `download failed, retried on the next crawl: ${error instanceof Error ? error.message : String(error)}`,
|
||||
mirrorPath: null, mirrorSize: null, retry: true,
|
||||
});
|
||||
skipped++;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const downloaded = await this.client.downloadFile(file.record);
|
||||
const relative = paths.get(entry.fileId);
|
||||
if (!relative) return;
|
||||
const absolute = resolveWithin(this.config.mirrorDir, relative);
|
||||
@@ -212,7 +305,7 @@ export class Indexer {
|
||||
await this.store.recordFileText({
|
||||
fileId: entry.fileId, name: entry.name, mimeType: entry.mimeType, size: entry.size,
|
||||
content: null,
|
||||
note: `download or extraction failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
note: `extraction failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
mirrorPath: null, mirrorSize: null,
|
||||
});
|
||||
skipped++;
|
||||
|
||||
462
src/mcp/prompts.ts
Normal file
462
src/mcp/prompts.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { ErrorCode, type GetPromptResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { z } from 'zod';
|
||||
import type { ServerContext } from '../context.ts';
|
||||
import { addDays, germanDate, germanDay, germanWeekday, isCalendarDate, schoolToday } from '../core/dates.ts';
|
||||
import { fold, joinSections, matchesAll, tokenize } from '../core/text.ts';
|
||||
import { courseUri, roomUri } from './resources.ts';
|
||||
import { readCourse } from './tools/content.ts';
|
||||
import { readRoom } from './tools/rooms.ts';
|
||||
import { ProtocolError, toProtocolError } from './tools/result.ts';
|
||||
import { readTimetable } from './tools/untis.ts';
|
||||
|
||||
/**
|
||||
* Prompts: ready-made requests a person picks from a menu, written in German
|
||||
* because the people using them are at a German school.
|
||||
*
|
||||
* Each embeds the course's overview as a resource, so Claude starts from the
|
||||
* real structure and ids instead of a name it has to look up first, and each
|
||||
* says where material hides and what cannot be read — the file manager, scans
|
||||
* and drafts — which is otherwise learnt one failed tool call at a time.
|
||||
*/
|
||||
|
||||
export interface Target {
|
||||
kind: 'course' | 'room';
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the three sources this server actually has.
|
||||
*
|
||||
* A prompt that tells Claude to read the class register on a deployment with no
|
||||
* WebUntis key, or the user's notes where there are none, spends a turn on a
|
||||
* tool that is not there and then explains itself — so the instructions name
|
||||
* only what exists.
|
||||
*/
|
||||
export interface Sources {
|
||||
notes: boolean;
|
||||
untis: boolean;
|
||||
}
|
||||
|
||||
function sourcesOf(context: ServerContext): Sources {
|
||||
return { notes: Boolean(context.config.notesDir), untis: Boolean(context.untis) };
|
||||
}
|
||||
|
||||
const COURSE_ARGUMENT = z
|
||||
.string()
|
||||
.describe('Kurs oder Raum: ein eindeutiger Teil des Namens oder die ID. Mehrere Wörter mit _ verbinden, z. B. Mathe_10b.');
|
||||
|
||||
export function registerPrompts(server: McpServer, context: ServerContext): void {
|
||||
server.registerPrompt(
|
||||
'zusammenfassung',
|
||||
{
|
||||
title: 'Kurs zusammenfassen',
|
||||
description:
|
||||
'Fasst einen Kurs oder Raum aus der Schulcloud zusammen: Themen, Aufgaben und die wichtigsten ' +
|
||||
'Materialien, jeweils mit Quelle.',
|
||||
argsSchema: {
|
||||
kurs: COURSE_ARGUMENT,
|
||||
fokus: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional: worauf die Zusammenfassung eingehen soll, z. B. ein Thema. Mehrere Wörter mit _ verbinden.'),
|
||||
},
|
||||
},
|
||||
async ({ kurs, fokus }) => {
|
||||
const target = await findTarget(context, kurs);
|
||||
return withOverview(
|
||||
context,
|
||||
target,
|
||||
'Zusammenfassung',
|
||||
summaryPrompt(target, argumentText(fokus), sourcesOf(context)),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
server.registerPrompt(
|
||||
'pruefungsvorbereitung',
|
||||
{
|
||||
title: 'Prüfungsvorbereitung',
|
||||
description:
|
||||
'Hilft bei der Vorbereitung auf eine Prüfung: Prüfungsstoff, Erklärungen, Übungsfragen und ein ' +
|
||||
'Lernplan, auf Grundlage des Kursmaterials und des Feedbacks zu den eigenen Abgaben.',
|
||||
argsSchema: {
|
||||
kurs: COURSE_ARGUMENT,
|
||||
thema: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional: Thema oder Stoff der Prüfung. Mehrere Wörter mit _ verbinden; ein - lässt es aus.'),
|
||||
datum: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional: Tag der Prüfung, z. B. 2026-10-02. Dann gibt es einen Lernplan bis dahin.'),
|
||||
},
|
||||
},
|
||||
async ({ kurs, thema, datum }) => {
|
||||
const target = await findTarget(context, kurs);
|
||||
const text = examPrompt(target, {
|
||||
topic: argumentText(thema),
|
||||
date: argumentText(datum),
|
||||
today: germanDate(new Date()),
|
||||
sources: sourcesOf(context),
|
||||
});
|
||||
return withOverview(context, target, 'Prüfungsvorbereitung', text);
|
||||
},
|
||||
);
|
||||
|
||||
// Only with WebUntis: without the timetable there is no way to know which
|
||||
// lessons a day holds, and a "Tagesvorbereitung" that has to ask is not one.
|
||||
const untis = context.untis;
|
||||
if (untis) {
|
||||
server.registerPrompt(
|
||||
'tagesvorbereitung',
|
||||
{
|
||||
title: 'Tagesvorbereitung',
|
||||
description:
|
||||
'Bereitet einen Schultag vor: die Stunden aus WebUntis samt Entfall und Vertretung, dazu das Neue ' +
|
||||
'aus den passenden Kursen der Schulcloud, Fälligkeiten und angekündigte Tests.',
|
||||
argsSchema: {
|
||||
tag: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional: heute (Standard), morgen, übermorgen oder ein Datum wie 2026-09-21.'),
|
||||
},
|
||||
},
|
||||
async ({ tag }) => {
|
||||
const date = resolveDay(argumentText(tag));
|
||||
let timetable: string;
|
||||
try {
|
||||
timetable = await readTimetable(untis, { from: date, to: date });
|
||||
} catch (error) {
|
||||
throw toProtocolError(error, `read the WebUntis timetable for ${date}`);
|
||||
}
|
||||
return {
|
||||
description: `Tagesvorbereitung: ${germanWeekday(date)}, ${germanDay(date)}`,
|
||||
messages: [
|
||||
{ role: 'user', content: { type: 'text', text: timetable } },
|
||||
{ role: 'user', content: { type: 'text', text: dayPrompt(date, sourcesOf(context)) } },
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The day a person meant: `heute`, `morgen`, `übermorgen`, `21.09.2026` or
|
||||
* `2026-09-21`.
|
||||
*
|
||||
* Evening preparation is the normal case for "morgen", and a German date is
|
||||
* what a German keyboard produces — refusing either would make the prompt
|
||||
* something to look up rather than to type.
|
||||
*/
|
||||
export function resolveDay(value: string | undefined, today: string = schoolToday()): string {
|
||||
const raw = value?.trim().toLowerCase();
|
||||
if (!raw || raw === 'heute') return today;
|
||||
if (raw === 'morgen') return addDays(today, 1);
|
||||
if (raw === 'übermorgen' || raw === 'uebermorgen') return addDays(today, 2);
|
||||
const german = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(raw);
|
||||
if (german) return `${german[3]}-${german[2]!.padStart(2, '0')}-${german[1]!.padStart(2, '0')}`;
|
||||
if (isCalendarDate(raw)) return raw;
|
||||
throw new ProtocolError(
|
||||
ErrorCode.InvalidParams,
|
||||
`„${value}“ ist kein Tag. Nimm heute, morgen, übermorgen oder ein Datum wie 2026-09-21.`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- arguments -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* An argument as a person typed it, or undefined when left out.
|
||||
*
|
||||
* Claude Code splits a prompt command on whitespace and drops the words that
|
||||
* do not fit a named argument, so a value of several words can only arrive
|
||||
* joined — `Erbrecht_und_Testament` — and a later argument can only be reached
|
||||
* by filling the earlier ones, which is what `-` is for.
|
||||
*/
|
||||
export function argumentText(value: string | undefined): string | undefined {
|
||||
const cleaned = value?.replace(/_+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
return cleaned && cleaned !== '-' ? cleaned : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the course or room a person meant.
|
||||
*
|
||||
* Matching is `search`'s — case- and umlaut-insensitive, every word must occur
|
||||
* — so `lf07` finds "LF07 - FIA24A/B - Sb/Ha" without anyone typing the slashes.
|
||||
* Looser readings only apply when a stricter one found nothing, which is what
|
||||
* keeps real course names choosable: `LF1` must not be ambiguous merely because
|
||||
* LF10 and LF12 exist, and "LF 11" is still found as `LF11`, since teachers
|
||||
* space the same codes differently. Anything that is not a single match is
|
||||
* refused with the candidates, because summarising the wrong course is worse
|
||||
* than asking again.
|
||||
*/
|
||||
export function resolveTarget(query: string, candidates: Target[]): Target {
|
||||
const wanted = query.trim();
|
||||
const byId = candidates.find((candidate) => candidate.id === wanted);
|
||||
if (byId) return byId;
|
||||
|
||||
const shown = argumentText(wanted) ?? wanted;
|
||||
const terms = tokenize(wanted);
|
||||
if (terms.length === 0) {
|
||||
throw new ProtocolError(ErrorCode.InvalidParams, 'Gib einen Kurs oder Raum an: einen Teil des Namens oder die ID.');
|
||||
}
|
||||
|
||||
const readings: ((name: string) => boolean)[] = [
|
||||
// the whole name, word for word
|
||||
(name) => tokenize(name).join(' ') === terms.join(' '),
|
||||
// every word as a whole word
|
||||
(name) => terms.every((term) => tokenize(name).includes(term)),
|
||||
// every word as part of a word
|
||||
(name) => matchesAll(name, terms),
|
||||
// every word, ignoring the spaces and punctuation inside the name
|
||||
(name) => terms.every((term) => fold(name).replace(/[^\p{L}\p{N}]+/gu, '').includes(term)),
|
||||
];
|
||||
for (const reading of readings) {
|
||||
const matches = candidates.filter((candidate) => reading(candidate.name));
|
||||
if (matches.length === 1) return matches[0]!;
|
||||
if (matches.length > 1) {
|
||||
const listed = matches
|
||||
.slice(0, 10)
|
||||
.map((candidate) => `${candidate.name} (${kindLabel(candidate)}, ID ${candidate.id})`)
|
||||
.join('; ');
|
||||
throw new ProtocolError(
|
||||
ErrorCode.InvalidParams,
|
||||
`„${shown}“ passt auf ${matches.length} Einträge: ${listed}${matches.length > 10 ? '; …' : ''}. ` +
|
||||
'Gib mehr vom Namen an (Wörter mit _ verbinden) oder die ID.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const available = candidates.map((candidate) => `${candidate.name} (${kindLabel(candidate)})`).join('; ');
|
||||
throw new ProtocolError(
|
||||
ErrorCode.InvalidParams,
|
||||
`Kein Kurs und kein Raum passt zu „${shown}“.${available ? ` Vorhanden: ${available}.` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
function kindLabel(target: Target): string {
|
||||
return target.kind === 'course' ? 'Kurs' : 'Raum';
|
||||
}
|
||||
|
||||
async function findTarget(context: ServerContext, query: string): Promise<Target> {
|
||||
let candidates: Target[];
|
||||
try {
|
||||
const [courses, rooms] = await Promise.all([
|
||||
context.client.listAllCourses(),
|
||||
// Rooms are optional here as everywhere: an account in none, or an
|
||||
// instance that refuses the route, must not cost the course lookup.
|
||||
context.client.listRooms().catch(() => []),
|
||||
]);
|
||||
candidates = [
|
||||
...courses.map((course): Target => ({ kind: 'course', id: course.id, name: course.title })),
|
||||
...rooms.map((room): Target => ({ kind: 'room', id: room.id, name: room.name })),
|
||||
];
|
||||
} catch (error) {
|
||||
throw toProtocolError(error, 'list courses');
|
||||
}
|
||||
return resolveTarget(query, candidates);
|
||||
}
|
||||
|
||||
async function withOverview(
|
||||
context: ServerContext,
|
||||
target: Target,
|
||||
title: string,
|
||||
instructions: string,
|
||||
): Promise<GetPromptResult> {
|
||||
const course = target.kind === 'course';
|
||||
let overview: string;
|
||||
try {
|
||||
overview = course ? await readCourse(context, target.id) : await readRoom(context, target.id);
|
||||
} catch (error) {
|
||||
throw toProtocolError(error, `read ${target.kind} ${target.id}`);
|
||||
}
|
||||
return {
|
||||
description: `${title}: ${target.name}`,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'resource',
|
||||
resource: { uri: course ? courseUri(target.id) : roomUri(target.id), mimeType: 'text/markdown', text: overview },
|
||||
},
|
||||
},
|
||||
{ role: 'user', content: { type: 'text', text: instructions } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// --- prompt texts ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The default for a call that names no sources: the tests and any older caller.
|
||||
* Naming nothing is safe; naming a tool that is not registered is not.
|
||||
*/
|
||||
const NO_SOURCES: Sources = { notes: false, untis: false };
|
||||
|
||||
const UNREADABLE =
|
||||
'Eingescannte PDFs ohne Textebene und noch nicht veröffentlichte Boards kannst du nicht lesen. ' +
|
||||
'Sag, was dir dadurch fehlt, statt es stillschweigend zu übergehen.';
|
||||
|
||||
export function summaryPrompt(target: Target, focus?: string, sources: Sources = NO_SOURCES): string {
|
||||
const course = target.kind === 'course';
|
||||
return joinSections([
|
||||
`Fasse ${course ? 'den Kurs' : 'den Raum'} „${target.name}“ für mich zusammen. Die Übersicht aus der Schulcloud ist angehängt.`,
|
||||
`So gehst du vor:\n${numbered([
|
||||
course
|
||||
? 'Lies das Material hinter der Übersicht: die Boards mit get_board, die Themen mit get_lesson und die Aufgaben mit get_task.'
|
||||
: 'Lies die Boards des Raums mit get_board.',
|
||||
course &&
|
||||
`Sieh dir auch die Kurs-Dateien an (fs_tree mit dem Pfad "/courses/${target.id}") und lies die aussagekräftigsten ` +
|
||||
'Dateien mit fs_read. Viele Lehrkräfte legen ihr Material nur dort ab, dann wirkt die Kursseite fast leer.',
|
||||
sources.notes &&
|
||||
'Sieh dir meine eigenen Mitschriften an (list_notes, bei Bedarf mit dem Fach). Sie sagen, was im ' +
|
||||
'Unterricht wirklich betont wurde — das steht in keinem hochgeladenen Material.',
|
||||
sources.untis &&
|
||||
'Frag das Klassenbuch (untis_lesson_topics mit dem Fach). Dort steht, was in welcher Stunde ' +
|
||||
'behandelt wurde, also die Reihenfolge des Unterrichts, die die Kursseite nicht verrät.',
|
||||
'Wenn es sehr viel Material gibt, lies zuerst das Neueste und das, was einen Überblick gibt (Arbeitsblätter, ' +
|
||||
'Präsentationen, Zusammenfassungen), und sag mir, was du ausgelassen hast.',
|
||||
focus && `Konzentriere dich auf: ${focus}.`,
|
||||
])}`,
|
||||
`Die Zusammenfassung enthält:\n${bulleted([
|
||||
`**Worum es geht:** Ziel und Inhalt ${course ? 'des Kurses' : 'des Raums'} in zwei, drei Sätzen.`,
|
||||
'**Themen:** die behandelten Themen, möglichst in der Reihenfolge des Unterrichts, jeweils mit den wichtigsten ' +
|
||||
'Inhalten und Fachbegriffen.',
|
||||
sources.notes &&
|
||||
'**Aus meinen Mitschriften:** was ich mir notiert habe und im Material nicht steht, als eigener Punkt ' +
|
||||
'und als Zitat kenntlich.',
|
||||
course && '**Aufgaben:** was zu erledigen war oder ist, mit Fälligkeit, ob ich abgegeben habe und wie es bewertet wurde.',
|
||||
'**Wichtige Materialien:** die Boards und Dateien, die man kennen sollte, mit Namen, damit ich sie wiederfinde.',
|
||||
'**Lücken:** was fehlt, unklar ist oder nicht gelesen werden konnte.',
|
||||
])}`,
|
||||
`Wichtig:\n${bulleted([
|
||||
'Stütze dich nur auf das, was du in der Schulcloud findest, nenne jeweils die Quelle (Board, Thema, Aufgabe ' +
|
||||
'oder Datei) und erfinde nichts dazu.',
|
||||
UNREADABLE,
|
||||
'Antworte auf Deutsch.',
|
||||
])}`,
|
||||
]);
|
||||
}
|
||||
|
||||
export function examPrompt(
|
||||
target: Target,
|
||||
options: { topic?: string; date?: string; today: string; sources?: Sources },
|
||||
): string {
|
||||
const course = target.kind === 'course';
|
||||
const sources = options.sources ?? NO_SOURCES;
|
||||
return joinSections([
|
||||
`Hilf mir, mich auf eine Prüfung ${course ? 'im Kurs' : 'im Raum'} „${target.name}“ vorzubereiten. ` +
|
||||
'Die Übersicht aus der Schulcloud ist angehängt.',
|
||||
options.topic
|
||||
? `Thema der Prüfung: ${options.topic}`
|
||||
: 'Das Thema der Prüfung steht noch nicht fest. Leite den wahrscheinlichen Prüfungsstoff aus dem Material ab ' +
|
||||
'und gewichte die neueren Inhalte stärker.',
|
||||
options.date && `Prüfungstermin: ${options.date} (heute ist ${options.today}).`,
|
||||
`So gehst du vor:\n${numbered([
|
||||
course
|
||||
? 'Sammle den Stoff: Lies die passenden Boards (get_board), Themen (get_lesson) und Aufgaben (get_task).'
|
||||
: 'Sammle den Stoff: Lies die passenden Boards des Raums mit get_board.',
|
||||
course &&
|
||||
`Durchsuche auch die Kurs-Dateien (fs_tree oder fs_find mit dem Pfad "/courses/${target.id}") und lies die ` +
|
||||
'passenden Dateien mit fs_read. Viele Lehrkräfte legen ihr Material nur dort ab.',
|
||||
options.topic && 'Mit search findest du das Thema auch im Text von Dateien.',
|
||||
sources.untis &&
|
||||
'Sieh im Klassenbuch nach, was tatsächlich unterrichtet wurde (untis_lesson_topics mit dem Fach, ' +
|
||||
'sonst mit einer periodId aus untis_timetable). Geprüft wird, was drankam — nicht, was hochgeladen ' +
|
||||
'wurde. Dort stehen oft auch die Ankündigung der Arbeit und ihr Stoff.',
|
||||
sources.notes &&
|
||||
'Lies meine eigenen Mitschriften zum Fach (list_notes, dann get_note; search findet sie auch im Text). ' +
|
||||
'Was ich mir aufgeschrieben habe, ist meist genau das, was die Lehrkraft betont hat — und damit der ' +
|
||||
'beste Hinweis auf den Prüfungsstoff. Wenn eine Mitschrift dem Material widerspricht, sag es.',
|
||||
course &&
|
||||
'Sieh dir meine Abgaben und das Feedback dazu an (get_task, list_submissions für diesen Kurs). Daran erkennst ' +
|
||||
'du, was ich schon kann und wo ich nacharbeiten sollte.',
|
||||
])}`,
|
||||
`Erstelle daraus:\n${bulleted([
|
||||
'**Prüfungsstoff:** die Themen, die drankommen können, jeweils mit Quelle. Was im Unterricht behandelt ' +
|
||||
'wurde, wiegt schwerer als Material, das nur bereitliegt.',
|
||||
'**Das Wichtigste:** Kernbegriffe, Definitionen, Zusammenhänge und Verfahren, knapp und verständlich erklärt.',
|
||||
'**Typische Aufgaben:** welche Arten von Aufgaben im Unterricht vorkamen, jeweils mit einem Beispiel.',
|
||||
'**Übungsfragen:** 8 bis 12 Fragen mit steigender Schwierigkeit. Die Lösungen stehen gesammelt am Ende, damit ' +
|
||||
'ich erst selbst nachdenken kann.',
|
||||
`**Lernplan:** ${options.date ? 'Tag für Tag bis zur Prüfung' : 'eine sinnvolle Reihenfolge der Themen'}, mit Zeit zum Wiederholen.`,
|
||||
course && '**Nacharbeiten:** Stellen, an denen Feedback oder Bewertungen Lücken zeigen, falls es welche gibt.',
|
||||
sources.notes &&
|
||||
'**Lücken in meinen Mitschriften:** Stunden zum Prüfungsstoff, zu denen ich nichts notiert habe — ' +
|
||||
'dort muss ich mich auf das Material verlassen.',
|
||||
])}`,
|
||||
`Wichtig:\n${bulleted([
|
||||
'Stütze dich auf das Material aus der Schulcloud und nenne die Quellen. Was du aus eigenem Wissen ergänzt, kennzeichnest du.',
|
||||
UNREADABLE,
|
||||
'Biete mir am Ende an, mich abzufragen.',
|
||||
'Antworte auf Deutsch.',
|
||||
])}`,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The morning briefing.
|
||||
*
|
||||
* Built around the one thing WebUntis knows and Schulcloud does not — which
|
||||
* lessons actually happen — and the one thing Schulcloud knows and WebUntis
|
||||
* does not: the material for them. The two lists of things to hand in stay
|
||||
* separate on purpose, because a teacher uses one or the other and a merged
|
||||
* list quietly drops half.
|
||||
*/
|
||||
export function dayPrompt(date: string, sources: Sources = NO_SOURCES): string {
|
||||
return joinSections([
|
||||
`Bereite mich auf den Schultag am ${germanWeekday(date)}, ${germanDay(date)} vor. Der Stundenplan aus ` +
|
||||
'WebUntis steht oben.',
|
||||
`So gehst du vor:\n${numbered([
|
||||
'Geh die Stunden des Tages durch. Entfallene Stunden lässt du weg, bei Vertretungen zählt, was stattfindet.',
|
||||
'Ordne jeder Stunde den passenden Kurs in der Schulcloud zu (list_courses). Die Fächerkürzel und die ' +
|
||||
'Kursnamen sehen unterschiedlich aus, also geh über das Fach, nicht über eine ID.',
|
||||
'Sieh dir zu jedem dieser Kurse an, was neu ist: what_changed seit dem letzten Schultag, dazu get_course ' +
|
||||
'und das, was daran hängt (get_board, get_lesson), sowie die Kurs-Dateien (fs_tree, fs_read).',
|
||||
'Mit untis_lesson_topics und der periodId einer Stunde siehst du, was im Unterricht zuletzt behandelt ' +
|
||||
'wurde. Daran erkennst du, was als Nächstes dran ist.',
|
||||
sources.notes &&
|
||||
'Sieh dir zu den Fächern des Tages meine eigenen Mitschriften der letzten Stunden an (list_notes mit ' +
|
||||
'dem Fach und since). Offene Fragen und Angekündigtes stehen oft nur dort.',
|
||||
'Prüfe, was fällig ist: list_tasks für die Schulcloud-Aufgaben und untis_homework für die Hausaufgaben ' +
|
||||
'aus dem Klassenbuch. Das sind zwei getrennte Listen.',
|
||||
'Lies die Notizen an den Stunden im Stundenplan. Angekündigte Tests und Leistungskontrollen stehen ' +
|
||||
'meistens dort und nirgends sonst.',
|
||||
])}`,
|
||||
`Die Vorbereitung enthält:\n${bulleted([
|
||||
'**Der Tag:** je Stunde Zeit, Fach, Raum und Lehrkraft, bei Änderungen mit einem Wort dazu.',
|
||||
'**Je Fach:** worum es zuletzt ging, was voraussichtlich dran ist, und was ich mir dafür ansehen sollte — ' +
|
||||
'jeweils mit Quelle, damit ich es wiederfinde.',
|
||||
sources.notes && '**Aus meinen Mitschriften:** offene Fragen und Merkposten aus den letzten Stunden.',
|
||||
'**Vorbereiten und mitbringen:** konkrete Punkte aus den Notizen, Hausaufgaben und Aufgaben.',
|
||||
'**Fällig:** Aufgaben und Hausaufgaben mit Datum, das von heute und morgen zuerst.',
|
||||
'**Angekündigt:** Tests, Leistungskontrollen und Prüfungen, mit Datum und Fach.',
|
||||
'**Sonstiges:** Neuigkeiten (list_news), die den Tag betreffen.',
|
||||
])}`,
|
||||
`Wichtig:\n${bulleted([
|
||||
'Wenn an dem Tag kein Unterricht ist, sag das in einem Satz, nenne den nächsten Schultag und bereite ' +
|
||||
'stattdessen kurz diesen vor.',
|
||||
'Stütze dich auf Schulcloud und WebUntis und nenne die Quelle. Was du aus eigenem Wissen ergänzt, ' +
|
||||
'kennzeichnest du.',
|
||||
UNREADABLE,
|
||||
'Halte es knapp: ich lese das morgens vor der Schule.',
|
||||
'Antworte auf Deutsch.',
|
||||
])}`,
|
||||
]);
|
||||
}
|
||||
|
||||
function numbered(items: (string | false | undefined)[]): string {
|
||||
return items
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.map((item, index) => `${index + 1}. ${item}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function bulleted(items: (string | false | undefined)[]): string {
|
||||
return items
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.map((item) => `- ${item}`)
|
||||
.join('\n');
|
||||
}
|
||||
103
src/mcp/resources.ts
Normal file
103
src/mcp/resources.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { ResourceTemplate, type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import type { ReadResourceResult, Resource } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { ServerContext } from '../context.ts';
|
||||
import { readCourse } from './tools/content.ts';
|
||||
import { readRoom } from './tools/rooms.ts';
|
||||
import { toProtocolError } from './tools/result.ts';
|
||||
|
||||
/**
|
||||
* Courses and rooms as MCP resources: things a person attaches to a message,
|
||||
* where tools are things the model decides to call.
|
||||
*
|
||||
* A resource carries exactly what get_course or get_room returns, so an
|
||||
* attached course and a fetched one read the same, and the ids in it lead to
|
||||
* the same tools. Deliberately coarse: a picker lists every resource at once,
|
||||
* which suits some twenty-odd courses and not the thousand-odd files.
|
||||
*
|
||||
* The labels are German because people read them in a picker; the content
|
||||
* stays the English Markdown the tools return, since the model reads that.
|
||||
*/
|
||||
|
||||
const MARKDOWN = 'text/markdown';
|
||||
|
||||
export function courseUri(courseId: string): string {
|
||||
return `schulcloud://courses/${courseId}`;
|
||||
}
|
||||
|
||||
export function roomUri(roomId: string): string {
|
||||
return `schulcloud://rooms/${roomId}`;
|
||||
}
|
||||
|
||||
export function registerResources(server: McpServer, context: ServerContext): void {
|
||||
server.registerResource(
|
||||
'course',
|
||||
new ResourceTemplate(courseUri('{courseId}'), {
|
||||
list: async () => ({
|
||||
resources: await listOrEmpty('courses', async () =>
|
||||
(await context.client.listAllCourses()).map((course) => entry(courseUri(course.id), 'Kurs', course.title)),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
{
|
||||
title: 'Kurs',
|
||||
description: 'Ein Kurs aus der Schulcloud: Boards, Themen, Aufgaben und Kurs-Dateien im Überblick.',
|
||||
mimeType: MARKDOWN,
|
||||
},
|
||||
async (uri, { courseId }) => read(uri, `read course ${courseId}`, () => readCourse(context, String(courseId))),
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'room',
|
||||
new ResourceTemplate(roomUri('{roomId}'), {
|
||||
list: async () => ({
|
||||
resources: await listOrEmpty('rooms', async () =>
|
||||
(await context.client.listRooms()).map((room) => entry(roomUri(room.id), 'Raum', room.name)),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
{
|
||||
title: 'Raum',
|
||||
description: 'Ein Raum aus der Schulcloud: seine Boards und wer darin ist.',
|
||||
mimeType: MARKDOWN,
|
||||
},
|
||||
async (uri, { roomId }) => read(uri, `read room ${roomId}`, () => readRoom(context, String(roomId))),
|
||||
);
|
||||
}
|
||||
|
||||
function entry(uri: string, kind: string, name: string): Resource {
|
||||
return {
|
||||
uri,
|
||||
name,
|
||||
title: name,
|
||||
// Claude Code's @ autocomplete shows the description in place of the
|
||||
// name, so a description without the name would leave every entry
|
||||
// reading as an opaque id.
|
||||
description: `${kind}: ${name}`,
|
||||
mimeType: MARKDOWN,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A listing that fails yields no entries instead of an error.
|
||||
*
|
||||
* Every resource kind is listed in one `resources/list` reply, so one refused
|
||||
* kind would otherwise cost the rest. A client may also treat a failed
|
||||
* listing as a failed server and drop its tools with it — and the tools are
|
||||
* where an expired token gets explained.
|
||||
*/
|
||||
async function listOrEmpty(kind: string, load: () => Promise<Resource[]>): Promise<Resource[]> {
|
||||
try {
|
||||
return await load();
|
||||
} catch (error) {
|
||||
console.error(`[schulcloud-mcp] resources: listing ${kind} failed:`, toProtocolError(error, `list ${kind}`).message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function read(uri: URL, action: string, render: () => Promise<string>): Promise<ReadResourceResult> {
|
||||
try {
|
||||
return { contents: [{ uri: uri.href, mimeType: MARKDOWN, text: await render() }] };
|
||||
} catch (error) {
|
||||
throw toProtocolError(error, action);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,20 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import type { Config } from '../config.ts';
|
||||
import { ServerContext } from '../context.ts';
|
||||
import type { Services } from '../services.ts';
|
||||
import { registerPrompts } from './prompts.ts';
|
||||
import { registerResources } from './resources.ts';
|
||||
import { registerContentTools } from './tools/content.ts';
|
||||
import { registerFileTools } from './tools/files.ts';
|
||||
import { registerFilesystemTools } from './tools/filesystem.ts';
|
||||
import { registerH5pTools } from './tools/h5p.ts';
|
||||
import { registerOverviewTools } from './tools/overview.ts';
|
||||
import { registerRawTool } from './tools/raw.ts';
|
||||
import { registerIndexTools } from './tools/index-tools.ts';
|
||||
import { registerNoteTools } from './tools/notes.ts';
|
||||
import { registerSearchTool } from './tools/search.ts';
|
||||
import { registerRoomTools } from './tools/rooms.ts';
|
||||
import { registerSubmissionTools } from './tools/submissions.ts';
|
||||
import { registerUntisTools } from './tools/untis.ts';
|
||||
|
||||
export const SERVER_NAME = 'schulcloud-mcp';
|
||||
export const SERVER_VERSION = '0.1.0';
|
||||
@@ -23,8 +30,16 @@ How the content is organised, and the usual path through it:
|
||||
text block, link and attached file in one call.
|
||||
- **Topics / lessons** ("Themen") — the older format. get_lesson.
|
||||
- **Tasks** ("Aufgaben") — homework. list_tasks across all courses, get_task for one.
|
||||
- **Quizzes and interactive exercises** are H5P elements on a board ("Quiz", "Test", "Übung"). get_board names
|
||||
one and says how many questions it holds; get_h5p returns all of them with the correct options marked — the
|
||||
player steps through one at a time, this does not. search finds their question text too.
|
||||
- **Files** hang off boards, lessons and tasks. Every listing shows file ids; download_file fetches one and
|
||||
extracts its text (PDF, Word, Excel, PowerPoint, OpenDocument) or returns an image inline.
|
||||
- **The file manager ("Dateien")** is a separate store with a real folder tree, browsed with the fs_* tools:
|
||||
/my (Persönliche Dateien), /courses/<course> (Kurs-Dateien), /teams/<team> (Team-Dateien) and /shared
|
||||
(Geteilte Dateien). **Many teachers put their material only here**, so when a course page looks empty or the
|
||||
worksheets are not on its boards, look in /courses/<course name>. fs_list and fs_tree browse, fs_find finds by
|
||||
name, fs_read opens a file. list_files and download_file do not see these files.
|
||||
- **Submissions** ("Abgaben") — what the user handed in. get_task shows that task's submission: the files,
|
||||
the graded flag, the grade, what the user wrote, and the teacher's written feedback. A grade is a
|
||||
percentage (0-100) or absent — there is no textual grade — and teachers often grade with the written
|
||||
@@ -34,10 +49,32 @@ How the content is organised, and the usual path through it:
|
||||
graded submission, say it was not found rather than that none was given. On a teacher account these
|
||||
tools report other people's submissions too.
|
||||
|
||||
**The user's own notes are a third source, and often the best one.** When the note tools are listed, the user
|
||||
keeps notes from their lessons as Markdown files: list_notes and get_note read them, search finds them by
|
||||
content, and add_note writes one. They record what a teacher said and stressed, which no upload does — so
|
||||
consult them whenever the question is what was covered in class, what a topic means "the way we did it", or
|
||||
what to revise for a test, and say when a note disagrees with the material. Notes are the user's own words:
|
||||
quote them, do not silently correct them.
|
||||
|
||||
**The timetable is not in Schulcloud.** When the untis_* tools are listed, the school's schedule lives in
|
||||
WebUntis and they are the only way to it: untis_timetable says which lessons a day actually holds, what was
|
||||
cancelled ("Entfall"), what is a substitution ("Vertretung") and what a teacher noted on a period — announced
|
||||
tests are usually in those notes. Schulcloud holds the material for those lessons, so the two go together:
|
||||
take the subject from untis_timetable, then find its course with list_courses. untis_homework is the class
|
||||
register's homework, which is a different list from Schulcloud's tasks; check both. untis_lesson_topics says
|
||||
what previous lessons of a subject actually covered — pass it a subject to read back over a whole term, which
|
||||
is the fastest way to reconstruct what a course has done. Those class-register entries are in the index too,
|
||||
so search finds them beside the Schulcloud material.
|
||||
|
||||
When the user names a topic rather than a course, use search — the API has no search endpoint, so it walks the
|
||||
courses and matches client-side, which takes a few seconds but covers board text and file names.
|
||||
|
||||
Everything here is read-only; nothing in this server can modify the account.`;
|
||||
The user can also attach a course or room directly (resources schulcloud://courses/<id> and schulcloud://rooms/<id>).
|
||||
An attached one is exactly what get_course or get_room returns, so do not fetch it again — continue from its ids.
|
||||
|
||||
Everything that touches Schulcloud and WebUntis is read-only: no tool here can change the school account,
|
||||
hand anything in, or mark anything done. The one exception writes nowhere near them — add_note, when it is
|
||||
listed, saves a file in the user's own notes directory.`;
|
||||
|
||||
export function createServer(config: Config, services?: Services): { server: McpServer; context: ServerContext } {
|
||||
const context = new ServerContext(config, services);
|
||||
@@ -48,11 +85,20 @@ export function createServer(config: Config, services?: Services): { server: Mcp
|
||||
|
||||
registerOverviewTools(server, context);
|
||||
registerContentTools(server, context);
|
||||
registerRoomTools(server, context);
|
||||
registerFileTools(server, context);
|
||||
registerFilesystemTools(server, context);
|
||||
registerH5pTools(server, context);
|
||||
registerSearchTool(server, context);
|
||||
registerSubmissionTools(server, context);
|
||||
registerIndexTools(server, context);
|
||||
// Only when a key is configured: the tools are not offered at all otherwise.
|
||||
registerUntisTools(server, context);
|
||||
// Same rule, for NOTES_DIR.
|
||||
registerNoteTools(server, context);
|
||||
registerRawTool(server, context);
|
||||
registerResources(server, context);
|
||||
registerPrompts(server, context);
|
||||
|
||||
return { server, context };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import type { ServerContext } from '../../context.ts';
|
||||
import { SchulcloudApiError } from '../../core/client.ts';
|
||||
import { formatBytes } from '../../core/extract.ts';
|
||||
import { dueLabel, formatDate, heading, htmlToText, joinSections, normalizeObjectId } from '../../core/text.ts';
|
||||
import { assembleBoard, type AssembledBoard, type AssembledElement } from '../../core/board.ts';
|
||||
import { forEachLimited } from '../../core/crawl.ts';
|
||||
import type { CourseBoardResponse, FileRecord, LessonResponse, TaskContent } from '../../core/types.ts';
|
||||
import { fetchLessonPadText } from '../../core/etherpad.ts';
|
||||
import type { FmListing } from '../../core/legacy-files.ts';
|
||||
import { fetchLessonTaskLinks, withScrapedIds } from '../../core/lesson-page.ts';
|
||||
import type {
|
||||
CourseBoardResponse,
|
||||
CourseTime,
|
||||
FileRecord,
|
||||
LegacyCourse,
|
||||
LessonLinkedTask,
|
||||
LessonResponse,
|
||||
ResolvedTask,
|
||||
} from '../../core/types.ts';
|
||||
import { failure, text, toToolError } from './result.ts';
|
||||
import { describeSubmission } from './submissions.ts';
|
||||
|
||||
@@ -27,8 +39,7 @@ export function registerContentTools(server: McpServer, context: ServerContext):
|
||||
},
|
||||
async ({ courseId }) => {
|
||||
try {
|
||||
const board = await context.client.getCourseBoard(courseId);
|
||||
return text(formatCourseBoard(board));
|
||||
return text(await readCourse(context, courseId));
|
||||
} catch (error) {
|
||||
return toToolError(error, `read course ${courseId}`);
|
||||
}
|
||||
@@ -55,9 +66,25 @@ export function registerContentTools(server: McpServer, context: ServerContext):
|
||||
async ({ boardId, includeFiles }) => {
|
||||
try {
|
||||
const schoolId = await context.schoolId();
|
||||
const board = await assembleBoard(context.client, boardId, schoolId, { resolveFiles: includeFiles });
|
||||
const board = await assembleBoard(context.client, boardId, schoolId, {
|
||||
resolveFiles: includeFiles,
|
||||
resolvePads: context.config,
|
||||
resolveH5p: true,
|
||||
});
|
||||
return text(formatBoard(board, includeFiles));
|
||||
} catch (error) {
|
||||
// An unpublished board 403s, while the course page lists its title
|
||||
// regardless — the course-board projection does not filter drafts.
|
||||
// Reporting that as "no permission" sends the reader looking for an
|
||||
// access problem that does not exist; a draft is the common cause.
|
||||
if (error instanceof SchulcloudApiError && error.status === 403) {
|
||||
return failure(
|
||||
`Board ${boardId} could not be opened (HTTP 403).\n\n` +
|
||||
`The usual reason is that it is still a draft: an unpublished board is listed on ` +
|
||||
`the course page with its title, but stays closed until the teacher publishes it. ` +
|
||||
`Otherwise this account genuinely has no access to it.`,
|
||||
);
|
||||
}
|
||||
return toToolError(error, `read board ${boardId}`);
|
||||
}
|
||||
},
|
||||
@@ -80,12 +107,30 @@ export function registerContentTools(server: McpServer, context: ServerContext):
|
||||
const schoolId = await context.schoolId();
|
||||
const [lesson, tasks, files] = await Promise.all([
|
||||
context.client.getLesson(lessonId),
|
||||
context.client.getLessonTasks(lessonId).catch(() => undefined),
|
||||
context.client.getLessonTasks(lessonId).catch(() => []),
|
||||
context.client
|
||||
.listFiles({ storageLocationId: schoolId, parentType: 'lessons', parentId: lessonId })
|
||||
.catch(() => undefined),
|
||||
]);
|
||||
return text(formatLesson(lesson, tasks?.data ?? [], files?.data ?? []));
|
||||
// The task bodies carry no id, so get_task cannot be pointed at them
|
||||
// without the topic page. Only paid for when the topic has tasks.
|
||||
const withIds =
|
||||
tasks.length > 0
|
||||
? withScrapedIds(tasks, await fetchLessonTaskLinks(context.config, lesson.courseId, lessonId))
|
||||
: tasks;
|
||||
// Pads are fetched only when the topic actually has one: each costs a
|
||||
// topic-page render to obtain the Etherpad session cookie.
|
||||
const padTexts = new Map<number, string>();
|
||||
await Promise.all(
|
||||
(lesson.contents ?? []).map(async (entry, index) => {
|
||||
if (entry.component !== 'Etherpad') return;
|
||||
const url = entry.content?.url;
|
||||
if (typeof url !== 'string') return;
|
||||
const padText = await fetchLessonPadText(context.config, lesson.courseId, lessonId, url);
|
||||
if (padText) padTexts.set(index, padText);
|
||||
}),
|
||||
);
|
||||
return text(formatLesson(lesson, withIds, files?.data ?? [], padTexts));
|
||||
} catch (error) {
|
||||
return toToolError(error, `read lesson ${lessonId}`);
|
||||
}
|
||||
@@ -132,6 +177,28 @@ export function registerContentTools(server: McpServer, context: ServerContext):
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A course's overview as Markdown: what get_course returns, and what the
|
||||
* course resource carries, so an attached course reads the same as a fetched one.
|
||||
*/
|
||||
export async function readCourse(context: ServerContext, courseId: string): Promise<string> {
|
||||
const [board, legacy, courseFiles] = await Promise.all([
|
||||
context.client.getCourseBoard(courseId),
|
||||
// The v3 projection carries no description, teachers, members or
|
||||
// timetable; /api/v1/courses still does. Optional on purpose — it
|
||||
// is a legacy route, so its absence must cost detail, not the call.
|
||||
context.client.getLegacyCourse(courseId).catch(() => undefined),
|
||||
// The course's file-manager area is a different store from the page.
|
||||
// Teachers who only upload files there leave the page itself empty,
|
||||
// and reporting "empty" then sends the reader away from the material.
|
||||
context.files.list({ area: 'courses', ownerId: courseId }).catch(() => undefined),
|
||||
]);
|
||||
const teachers = legacy
|
||||
? await context.resolveNames([...(legacy.teacherIds ?? []), ...(legacy.substitutionIds ?? [])])
|
||||
: { names: [], unresolved: 0 };
|
||||
return formatCourseBoard(board, legacy, teachers, courseFiles);
|
||||
}
|
||||
|
||||
// --- task lookup -------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -142,7 +209,7 @@ export function registerContentTools(server: McpServer, context: ServerContext):
|
||||
* to learn which course the task belongs to (unless told), then read the
|
||||
* description off that course's page.
|
||||
*/
|
||||
async function findTask(context: ServerContext, taskId: string, courseId?: string): Promise<TaskContent | undefined> {
|
||||
async function findTask(context: ServerContext, taskId: string, courseId?: string): Promise<ResolvedTask | undefined> {
|
||||
if (courseId) {
|
||||
const fromCourse = await taskFromCourse(context, courseId, taskId);
|
||||
if (fromCourse) return fromCourse;
|
||||
@@ -168,7 +235,7 @@ async function findTask(context: ServerContext, taskId: string, courseId?: strin
|
||||
// back to scanning course pages costs ~26 requests and a few seconds, which
|
||||
// is a fair price for the tool working instead of claiming the id is wrong.
|
||||
const courses = await context.client.listAllCourses().catch(() => []);
|
||||
let found: TaskContent | undefined;
|
||||
let found: ResolvedTask | undefined;
|
||||
await forEachLimited(courses, 6, async (course) => {
|
||||
if (found) return;
|
||||
const fromCourse = await taskFromCourse(context, course.id, taskId);
|
||||
@@ -181,7 +248,7 @@ async function taskFromCourse(
|
||||
context: ServerContext,
|
||||
courseId: string,
|
||||
taskId: string,
|
||||
): Promise<TaskContent | undefined> {
|
||||
): Promise<ResolvedTask | undefined> {
|
||||
const board = await context.client.getCourseBoard(courseId).catch(() => undefined);
|
||||
if (!board) return undefined;
|
||||
for (const element of board.elements) {
|
||||
@@ -189,12 +256,32 @@ async function taskFromCourse(
|
||||
return { ...element.content, courseId, courseName: element.content.courseName ?? board.title };
|
||||
}
|
||||
}
|
||||
|
||||
// A task can hang off a topic rather than the course page, and those are not
|
||||
// listed as task elements — only as a count on the topic. Without this the
|
||||
// task is unreachable: not in the lists (a submitted, past-due task is in
|
||||
// neither open nor finished) and not on the course page either.
|
||||
for (const element of board.elements) {
|
||||
if (element.type !== 'lesson' || !element.content.numberOfPublishedTasks) continue;
|
||||
const links = await fetchLessonTaskLinks(context.config, courseId, element.content.id);
|
||||
if (!links.some((link) => link.id === taskId)) continue;
|
||||
const tasks = await context.client.getLessonTasks(element.content.id).catch(() => []);
|
||||
const match = withScrapedIds(tasks, links).find((task) => task.id === taskId);
|
||||
if (match) {
|
||||
return { ...match, courseId, courseName: match.courseName ?? board.title, lessonName: element.content.name };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// --- formatting --------------------------------------------------------
|
||||
|
||||
function formatCourseBoard(board: CourseBoardResponse): string {
|
||||
function formatCourseBoard(
|
||||
board: CourseBoardResponse,
|
||||
legacy?: LegacyCourse,
|
||||
teachers: { names: string[]; unresolved: number } = { names: [], unresolved: 0 },
|
||||
courseFiles?: FmListing,
|
||||
): string {
|
||||
const boards: string[] = [];
|
||||
const lessons: string[] = [];
|
||||
const tasks: string[] = [];
|
||||
@@ -214,19 +301,101 @@ function formatCourseBoard(board: CourseBoardResponse): string {
|
||||
}
|
||||
}
|
||||
|
||||
const about = joinSections([
|
||||
htmlToText(legacy?.description)?.trim() || undefined,
|
||||
formatTeachers(teachers),
|
||||
legacy?.userIds?.length ? `**Members:** ${legacy.userIds.length}` : undefined,
|
||||
formatCourseTimes(legacy?.times),
|
||||
]);
|
||||
|
||||
const filesSection = formatCourseFiles(board.roomId, courseFiles);
|
||||
|
||||
if (boards.length + lessons.length + tasks.length === 0) {
|
||||
return `${heading(2, board.title)}\n\nThis course page is empty.`;
|
||||
return joinSections([
|
||||
heading(2, board.title),
|
||||
`Course id: \`${board.roomId}\``,
|
||||
about,
|
||||
filesSection
|
||||
? 'No boards, topics or tasks on the course page — the material is in the course files instead.'
|
||||
: 'This course page is empty, and the course has no files in the file manager either.',
|
||||
filesSection,
|
||||
]);
|
||||
}
|
||||
|
||||
return joinSections([
|
||||
heading(2, board.title),
|
||||
`Course id: \`${board.roomId}\``,
|
||||
about,
|
||||
boards.length > 0 && joinSections([heading(3, `Boards (${boards.length})`), boards.join('\n'), 'Read one with get_board.']),
|
||||
lessons.length > 0 && joinSections([heading(3, `Topics (${lessons.length})`), lessons.join('\n'), 'Read one with get_lesson.']),
|
||||
tasks.length > 0 && joinSections([heading(3, `Tasks (${tasks.length})`), tasks.join('\n'), 'Read one with get_task.']),
|
||||
filesSection,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The course's own file-manager area ("Kurs-Dateien"), when it holds anything.
|
||||
*
|
||||
* Only the top level is fetched — one page — so this says how much is there
|
||||
* and where, rather than listing it; fs_tree does that.
|
||||
*/
|
||||
function formatCourseFiles(courseId: string, listing: FmListing | undefined): string | undefined {
|
||||
if (!listing || listing.directories.length + listing.files.length === 0) return undefined;
|
||||
const names = [...listing.directories.map((entry) => `${entry.name}/`), ...listing.files.map((entry) => entry.name)];
|
||||
const shown = names.slice(0, 8).map((name) => `- ${name}`).join('\n');
|
||||
return joinSections([
|
||||
heading(3, 'Course files (Kurs-Dateien)'),
|
||||
`${listing.directories.length} folder(s) and ${listing.files.length} file(s) at the top level, newest first:`,
|
||||
shown + (names.length > 8 ? `\n- … and ${names.length - 8} more` : ''),
|
||||
`See everything with fs_tree path "/courses/${courseId}", read one with fs_read.`,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who teaches the course.
|
||||
*
|
||||
* A student may not read their teachers' user records, so names are often
|
||||
* unavailable; say how many there are rather than printing bare ids, which
|
||||
* are no use to a reader and look like a bug.
|
||||
*/
|
||||
function formatTeachers(teachers: { names: string[]; unresolved: number }): string | undefined {
|
||||
const { names, unresolved } = teachers;
|
||||
if (names.length === 0 && unresolved === 0) return undefined;
|
||||
if (names.length === 0) {
|
||||
return `**Taught by:** ${unresolved} teacher(s) — names are not visible to this account`;
|
||||
}
|
||||
const rest = unresolved > 0 ? ` and ${unresolved} more (name not visible to this account)` : '';
|
||||
return `**Taught by:** ${names.join(', ')}${rest}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A course's weekly timetable.
|
||||
*
|
||||
* `times` is the closest thing to a calendar the API exposes — the calendar
|
||||
* service itself is not part of the v3 document. `startTime` is milliseconds
|
||||
* since midnight and `weekday` is 0-based from Monday, as the legacy client
|
||||
* renders it.
|
||||
*/
|
||||
function formatCourseTimes(times: CourseTime[] | undefined): string | undefined {
|
||||
if (!times || times.length === 0) return undefined;
|
||||
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
const rows = times
|
||||
.slice()
|
||||
.sort((a, b) => (a.weekday ?? 0) - (b.weekday ?? 0) || (a.startTime ?? 0) - (b.startTime ?? 0))
|
||||
.map((slot) => {
|
||||
const day = days[slot.weekday ?? 0] ?? `day ${slot.weekday}`;
|
||||
const room = slot.room ? `, room ${slot.room}` : '';
|
||||
return `- ${day} ${clockFromMs(slot.startTime)}${slot.duration ? `–${clockFromMs((slot.startTime ?? 0) + slot.duration)}` : ''}${room}`;
|
||||
});
|
||||
return joinSections([`**Weekly schedule:**`, rows.join('\n')]);
|
||||
}
|
||||
|
||||
function clockFromMs(ms: number | undefined): string {
|
||||
if (ms === undefined) return '?';
|
||||
const total = Math.floor(ms / 60_000);
|
||||
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatBoard(board: AssembledBoard, includeFiles: boolean): string {
|
||||
const columns = board.columns.map((column) => {
|
||||
const cards = column.cards.map((card) => {
|
||||
@@ -252,6 +421,14 @@ function formatBoard(board: AssembledBoard, includeFiles: boolean): string {
|
||||
]);
|
||||
}
|
||||
|
||||
/** Indents a pad's body so it reads as quoted content, not as board structure. */
|
||||
function indent(body: string): string {
|
||||
return body
|
||||
.split('\n')
|
||||
.map((line) => ` > ${line}`.trimEnd())
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatElement(element: AssembledElement, includeFiles: boolean): string {
|
||||
switch (element.type) {
|
||||
case 'richText': {
|
||||
@@ -260,27 +437,71 @@ function formatElement(element: AssembledElement, includeFiles: boolean): string
|
||||
}
|
||||
case 'link': {
|
||||
const label = element.text?.trim();
|
||||
return element.url ? `- Link: ${label && label !== element.url ? `${label} — ${element.url}` : element.url}` : '';
|
||||
if (!element.url) return '';
|
||||
const head = `- Link: ${label && label !== element.url ? `${label} — ${element.url}` : element.url}`;
|
||||
const note = htmlToText(element.description)?.trim();
|
||||
return note ? `${head}\n${indent(note)}` : head;
|
||||
}
|
||||
case 'file':
|
||||
case 'fileFolder':
|
||||
case 'drawing': {
|
||||
const caption = element.text ? ` — caption: ${element.text}` : '';
|
||||
if (!includeFiles) return `- ${element.type} element \`${element.id}\`${caption}`;
|
||||
// Alt text describes the picture itself, so it belongs on the line
|
||||
// whether or not the file records could be listed.
|
||||
const alt = element.alternativeText?.trim() ? ` — alt: ${element.alternativeText.trim()}` : '';
|
||||
const note = element.description?.trim() ? ` — ${element.description.trim()}` : '';
|
||||
const extra = `${caption}${alt}${note}`;
|
||||
if (!includeFiles) return `- ${element.type} element \`${element.id}\`${extra}`;
|
||||
if (element.fileError) return `- ${element.type} element \`${element.id}\` — could not list files (${element.fileError})`;
|
||||
if (element.files.length === 0) return `- ${element.type} element \`${element.id}\` — no files${caption}`;
|
||||
return element.files.map((file) => `- ${formatFileLine(file)}${caption}`).join('\n');
|
||||
if (element.files.length === 0) return `- ${element.type} element \`${element.id}\` — no files${extra}`;
|
||||
return element.files.map((file) => `- ${formatFileLine(file)}${extra}`).join('\n');
|
||||
}
|
||||
case 'collaborativeTextEditor': {
|
||||
const title = element.text ? ` — ${element.text}` : '';
|
||||
// The board API returns these with empty content; the text comes from
|
||||
// the pad itself (core/etherpad.ts). Absent means empty or unreadable,
|
||||
// which for a pad is usually "nobody has written in it yet".
|
||||
if (!element.padText) {
|
||||
return `- Collaborative text document \`${element.id}\`${title} (empty, or its contents could not be read)`;
|
||||
}
|
||||
return [`- Collaborative text document \`${element.id}\`${title}:`, indent(element.padText)].join('\n');
|
||||
}
|
||||
case 'externalTool': {
|
||||
// The configured-tool id is what `api_get /api/v3/tools/...` needs to
|
||||
// say which tool this actually is; without it the element is opaque.
|
||||
const tool = element.contextExternalToolId
|
||||
? ` — configured tool \`${element.contextExternalToolId}\``
|
||||
: '';
|
||||
return `- External tool${element.text ? `: ${element.text}` : ''} \`${element.id}\`${tool}`;
|
||||
}
|
||||
case 'collaborativeTextEditor':
|
||||
return `- Collaborative text document \`${element.id}\`${element.text ? ` — ${element.text}` : ''} (contents not available through the API)`;
|
||||
case 'externalTool':
|
||||
return `- External tool${element.text ? `: ${element.text}` : ''} \`${element.id}\``;
|
||||
case 'videoConference':
|
||||
return `- Video conference \`${element.id}\``;
|
||||
case 'h5p':
|
||||
return `- H5P interactive content \`${element.id}\``;
|
||||
case 'deleted':
|
||||
return '- _(deleted element)_';
|
||||
return `- Video conference${element.text ? `: ${element.text}` : ''} \`${element.id}\``;
|
||||
case 'h5p': {
|
||||
// Schulcloud has no quiz of its own: interactive exercises are H5P, and
|
||||
// this id is the only way to reach the content behind one.
|
||||
const content = element.h5pContentId ? ` \`${element.h5pContentId}\`` : '';
|
||||
if (!element.h5p) {
|
||||
return `- H5P interactive content \`${element.id}\`${content ? ` — H5P content${content}` : ''}`;
|
||||
}
|
||||
// Summarised rather than inlined: a question set runs to twenty
|
||||
// questions with their options, which would bury the rest of the board.
|
||||
// get_h5p prints them, and search reaches their text either way.
|
||||
const quiz = element.h5p;
|
||||
const kinds = [...new Set(quiz.questions.map((question) => question.kind))].join(', ');
|
||||
const count = quiz.questions.length;
|
||||
const unread = quiz.unmodelled.length > 0 ? `, ${quiz.unmodelled.length} part(s) this server cannot model` : '';
|
||||
return (
|
||||
`- **H5P exercise: ${quiz.title}** — ${count} question(s)${kinds ? ` (${kinds})` : ''}${unread}, ` +
|
||||
`content${content} — all of it with get_h5p`
|
||||
);
|
||||
}
|
||||
case 'deleted': {
|
||||
// Saying what it was beats "(deleted element)": the title often names
|
||||
// the material a student is looking for and cannot find.
|
||||
const was = element.deletedElementType ? ` ${element.deletedElementType}` : '';
|
||||
const title = element.text ? `: ${element.text}` : '';
|
||||
return `- _(deleted${was} element${title})_`;
|
||||
}
|
||||
default:
|
||||
return `- ${element.type} element \`${element.id}\``;
|
||||
}
|
||||
@@ -292,12 +513,17 @@ export function formatFileLine(file: FileRecord): string {
|
||||
return `File: **${file.name}** (\`${file.id}\`, ${file.mimeType}, ${formatBytes(file.size)})${blocked}${pending}`;
|
||||
}
|
||||
|
||||
function formatLesson(lesson: LessonResponse, tasks: TaskContent[], files: FileRecord[]): string {
|
||||
const sections = (lesson.contents ?? []).map((entry) => {
|
||||
function formatLesson(
|
||||
lesson: LessonResponse,
|
||||
tasks: LessonLinkedTask[],
|
||||
files: FileRecord[],
|
||||
padTexts: Map<number, string> = new Map(),
|
||||
): string {
|
||||
const sections = (lesson.contents ?? []).map((entry, index) => {
|
||||
const title = entry.title?.trim();
|
||||
const component = entry.component ?? 'unknown';
|
||||
const hidden = entry.hidden ? ' [hidden]' : '';
|
||||
const body = formatLessonComponent(component, entry.content ?? {});
|
||||
const body = formatLessonComponent(component, entry.content ?? {}, padTexts.get(index));
|
||||
return joinSections([heading(4, `${title || component}${hidden}`), body || `_(${component} content, nothing to show)_`]);
|
||||
});
|
||||
|
||||
@@ -316,27 +542,56 @@ function formatLesson(lesson: LessonResponse, tasks: TaskContent[], files: FileR
|
||||
tasks.length > 0 &&
|
||||
joinSections([
|
||||
heading(3, `Tasks in this lesson (${tasks.length})`),
|
||||
tasks.map((task) => `- **${task.name}** (\`${task.id}\`) — ${dueLabel(task.dueDate)}`).join('\n'),
|
||||
tasks
|
||||
.map((task) => {
|
||||
const id = task.id ? ` (\`${task.id}\`)` : '';
|
||||
return `- **${task.name}**${id} — ${dueLabel(task.dueDate)}`;
|
||||
})
|
||||
.join('\n'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
function formatLessonComponent(component: string, content: Record<string, unknown>): string {
|
||||
function formatLessonComponent(
|
||||
component: string,
|
||||
content: Record<string, unknown>,
|
||||
padText?: string,
|
||||
): string {
|
||||
if (component === 'text' && typeof content.text === 'string') return htmlToText(content.text);
|
||||
if (component === 'resources' && Array.isArray(content.resources)) {
|
||||
return content.resources
|
||||
.map((resource) => {
|
||||
const entry = resource as { title?: string; url?: string; description?: string };
|
||||
return `- ${entry.title ?? 'Resource'}${entry.url ? ` — ${entry.url}` : ''}`;
|
||||
const note = entry.description?.trim();
|
||||
const head = `- ${entry.title ?? 'Resource'}${entry.url ? ` — ${entry.url}` : ''}`;
|
||||
return note ? `${head}\n${indent(note)}` : head;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
// A topic's Etherpad: the same collaborative document a column board can
|
||||
// hold, reached by a stored url instead of an element id. The url is data
|
||||
// and may name another deployment, in which case the text is unavailable
|
||||
// and the link is the honest answer.
|
||||
if (component === 'Etherpad') {
|
||||
const url = typeof content.url === 'string' ? content.url : undefined;
|
||||
const note = typeof content.description === 'string' ? content.description.trim() : '';
|
||||
const lines = [url ? `- Collaborative text document — ${url}` : '- Collaborative text document'];
|
||||
if (note) lines.push(indent(note));
|
||||
if (padText) lines.push(indent(padText));
|
||||
else if (url) lines.push(indent('_(empty, or its contents could not be read)_'));
|
||||
return lines.join('\n');
|
||||
}
|
||||
// A GeoGebra applet. Only the material id is stored, so name it and let the
|
||||
// reader follow it rather than rendering an empty section.
|
||||
if (component === 'geoGebra' && typeof content.materialId === 'string') {
|
||||
return `- GeoGebra applet \`${content.materialId}\` — https://www.geogebra.org/m/${content.materialId}`;
|
||||
}
|
||||
if (typeof content.url === 'string') return `- ${content.url}`;
|
||||
if (typeof content.title === 'string') return content.title;
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatTask(task: TaskContent, files: FileRecord[], submission?: string): string {
|
||||
function formatTask(task: ResolvedTask, files: FileRecord[], submission?: string): string {
|
||||
const description = htmlToText(task.description);
|
||||
return joinSections([
|
||||
heading(2, task.name),
|
||||
@@ -346,7 +601,11 @@ function formatTask(task: TaskContent, files: FileRecord[], submission?: string)
|
||||
task.lessonName ? `- Topic: ${task.lessonName}` : undefined,
|
||||
`- Available from: ${formatDate(task.availableDate)}`,
|
||||
`- Due: ${dueLabel(task.dueDate)}`,
|
||||
`- Submitted: ${task.status.submitted}/${task.status.maxSubmissions}${task.status.graded > 0 ? ', graded' : ''}`,
|
||||
// Absent for a task found through a topic: that projection reports no
|
||||
// counts. The submission section below carries the authoritative state.
|
||||
task.status
|
||||
? `- Submitted: ${task.status.submitted}/${task.status.maxSubmissions}${task.status.graded > 0 ? ', graded' : ''}`
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import type { ServerContext } from '../../context.ts';
|
||||
import type { DownloadedFile } from '../../core/client.ts';
|
||||
import { extractContent, formatBytes } from '../../core/extract.ts';
|
||||
import { formatDate, heading, joinSections } from '../../core/text.ts';
|
||||
import { FILE_PARENT_TYPES, type FileParentType } from '../../core/types.ts';
|
||||
@@ -16,9 +17,11 @@ export function registerFileTools(server: McpServer, context: ServerContext): vo
|
||||
{
|
||||
title: 'List files of an entity',
|
||||
description:
|
||||
'Files attached to one entity. Most of the time you do not need this — get_board, get_lesson and ' +
|
||||
'get_task already list their own attachments. Reach for it to enumerate a course\'s own file area, ' +
|
||||
'or a single board element\'s files (parentType "boardnodes", parentId = the element id).',
|
||||
'Attachments on one entity in files-storage: a board element, a topic, a task, a submission. Most of the ' +
|
||||
'time you do not need this — get_board, get_lesson and get_task already list their own attachments. ' +
|
||||
'**Not for a course\'s files, personal files, team files or shared files**: those live in the file ' +
|
||||
'manager ("Dateien"), a separate store this tool cannot see — it answers 0 for a course holding dozens of ' +
|
||||
'worksheets. Use fs_list, fs_tree, fs_find and fs_read for them.',
|
||||
inputSchema: {
|
||||
parentType: z
|
||||
.enum(FILE_PARENT_TYPES as [FileParentType, ...FileParentType[]])
|
||||
@@ -30,11 +33,22 @@ export function registerFileTools(server: McpServer, context: ServerContext): vo
|
||||
async ({ parentType, parentId }) => {
|
||||
try {
|
||||
const schoolId = await context.schoolId();
|
||||
const page = await context.client.listFiles({ storageLocationId: schoolId, parentType, parentId });
|
||||
const [page, stats] = await Promise.all([
|
||||
context.client.listFiles({ storageLocationId: schoolId, parentType, parentId }),
|
||||
// Cheap, and it is the only way to see that a parent holds files
|
||||
// the listing paged past.
|
||||
context.client.getParentFileStats(parentType, parentId).catch(() => undefined),
|
||||
]);
|
||||
if (page.data.length === 0) return text(`No files attached to ${parentType} ${parentId}.`);
|
||||
const total =
|
||||
stats && stats.fileCount > page.data.length
|
||||
? ` — ${stats.fileCount} in total, ${formatBytes(stats.totalSizeInBytes)}`
|
||||
: stats
|
||||
? ` — ${formatBytes(stats.totalSizeInBytes)} in total`
|
||||
: '';
|
||||
return text(
|
||||
joinSections([
|
||||
heading(2, `Files on ${parentType} ${parentId} (${page.data.length})`),
|
||||
heading(2, `Files on ${parentType} ${parentId} (${page.data.length})${total}`),
|
||||
page.data.map((file) => `- ${formatFileLine(file)} — uploaded ${formatDate(file.createdAt)}`).join('\n'),
|
||||
'Read one with download_file.',
|
||||
]),
|
||||
@@ -50,9 +64,10 @@ export function registerFileTools(server: McpServer, context: ServerContext): vo
|
||||
{
|
||||
title: 'Download and read a file',
|
||||
description:
|
||||
'Fetches a file and returns its contents. PDFs, Word, Excel, PowerPoint and OpenDocument files are ' +
|
||||
'extracted to text; images come back inline so you can look at them; anything else reports its type. ' +
|
||||
'Pass raw=true to get base64 bytes instead of extracted text.',
|
||||
'Fetches a board, topic or task attachment and returns its contents. PDFs, Word, Excel, PowerPoint and ' +
|
||||
'OpenDocument files are extracted to text; images come back inline so you can look at them; anything ' +
|
||||
'else reports its type. Pass raw=true to get base64 bytes instead of extracted text. For files from the ' +
|
||||
'file manager (Persönliche Dateien, Kurs-Dateien, Team-Dateien, Geteilte Dateien) use fs_read instead.',
|
||||
inputSchema: {
|
||||
fileId: z.string().describe('File record id, from get_board, get_task, get_lesson or list_files.'),
|
||||
raw: z
|
||||
@@ -80,7 +95,12 @@ export function registerFileTools(server: McpServer, context: ServerContext): vo
|
||||
`"${record.name}" was blocked by the instance's virus scanner and will not be downloaded.`,
|
||||
);
|
||||
}
|
||||
const file = await context.client.downloadFile(record);
|
||||
const [file, uploader] = await Promise.all([
|
||||
context.client.downloadFile(record),
|
||||
// Who put the file there is often the quickest way to tell a
|
||||
// teacher's material apart from a classmate's upload.
|
||||
record.creatorId ? context.userName(record.creatorId) : Promise.resolve(undefined),
|
||||
]);
|
||||
const header = [
|
||||
heading(2, record.name),
|
||||
[
|
||||
@@ -88,7 +108,7 @@ export function registerFileTools(server: McpServer, context: ServerContext): vo
|
||||
`- Type: ${record.mimeType}`,
|
||||
`- Size: ${formatBytes(record.size)}`,
|
||||
`- Attached to: ${record.parentType} \`${record.parentId}\``,
|
||||
`- Uploaded: ${formatDate(record.createdAt)}`,
|
||||
`- Uploaded: ${formatDate(record.createdAt)}${uploader ? ` by ${uploader}` : ''}`,
|
||||
record.securityCheckStatus !== 'verified'
|
||||
? `- Virus scan: ${record.securityCheckStatus}`
|
||||
: undefined,
|
||||
@@ -100,50 +120,99 @@ export function registerFileTools(server: McpServer, context: ServerContext): vo
|
||||
.join('\n'),
|
||||
].join('\n\n');
|
||||
|
||||
if (raw) {
|
||||
return text(
|
||||
joinSections([
|
||||
header,
|
||||
`Base64 (${file.bytes.length} bytes):`,
|
||||
'```',
|
||||
file.bytes.toString('base64'),
|
||||
'```',
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const extraction = await extractContent(
|
||||
file.bytes,
|
||||
file.mimeType || record.mimeType,
|
||||
record.name,
|
||||
maxChars ?? context.config.maxExtractedChars,
|
||||
);
|
||||
|
||||
if (extraction.kind === 'image' && extraction.image) {
|
||||
const result: CallToolResult = {
|
||||
content: [
|
||||
{ type: 'text', text: joinSections([header, extraction.note]) },
|
||||
{ type: 'image', data: extraction.image.base64, mimeType: extraction.image.mimeType },
|
||||
],
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
if (extraction.kind === 'text') {
|
||||
const body = extraction.text?.trim();
|
||||
return text(
|
||||
joinSections([
|
||||
header,
|
||||
extraction.note,
|
||||
body ? joinSections([heading(3, 'Contents'), body]) : '_(the file contains no extractable text)_',
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return text(joinSections([header, extraction.note]));
|
||||
return await renderFileContent(context, header, file, {
|
||||
name: record.name,
|
||||
mimeType: record.mimeType,
|
||||
raw,
|
||||
maxChars,
|
||||
// Nothing extractable — but files-storage may still be able to render
|
||||
// the file as a picture. That is the whole answer for an image-only
|
||||
// PDF: its pages *are* pictures, so a rasterised preview is readable
|
||||
// where the bytes are not, and it needs no OCR on our side.
|
||||
fallbackImage:
|
||||
record.previewStatus === 'preview_possible'
|
||||
? async () => {
|
||||
const preview = await context.client.getFilePreview(record, 500).catch(() => undefined);
|
||||
return preview && preview.mimeType.startsWith('image/') ? preview : undefined;
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
return toToolError(error, `download file ${fileId}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a downloaded file for a tool result: base64 when asked for, an image
|
||||
* inline, extracted text, or — for a format with no extractor — its note.
|
||||
*
|
||||
* Shared by download_file (files-storage) and fs_read (the file manager), which
|
||||
* differ only in how the bytes were obtained and in what the header says.
|
||||
* `fallbackImage` is download_file's preview route; the file manager has none.
|
||||
*/
|
||||
export async function renderFileContent(
|
||||
context: ServerContext,
|
||||
header: string,
|
||||
file: DownloadedFile,
|
||||
options: {
|
||||
name: string;
|
||||
mimeType?: string;
|
||||
raw: boolean;
|
||||
maxChars?: number;
|
||||
fallbackImage?: () => Promise<DownloadedFile | undefined>;
|
||||
},
|
||||
): Promise<CallToolResult> {
|
||||
if (options.raw) {
|
||||
return text(joinSections([header, `Base64 (${file.bytes.length} bytes):`, '```', file.bytes.toString('base64'), '```']));
|
||||
}
|
||||
|
||||
const extraction = await extractContent(
|
||||
file.bytes,
|
||||
file.mimeType && file.mimeType !== 'application/octet-stream' ? file.mimeType : (options.mimeType ?? file.mimeType),
|
||||
options.name,
|
||||
options.maxChars ?? context.config.maxExtractedChars,
|
||||
);
|
||||
|
||||
if (extraction.kind === 'image' && extraction.image) {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: joinSections([header, extraction.note]) },
|
||||
{ type: 'image', data: extraction.image.base64, mimeType: extraction.image.mimeType },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (extraction.kind === 'text') {
|
||||
const body = extraction.text?.trim();
|
||||
return text(
|
||||
joinSections([
|
||||
header,
|
||||
extraction.note,
|
||||
body ? joinSections([heading(3, 'Contents'), body]) : '_(the file contains no extractable text)_',
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const image = await options.fallbackImage?.();
|
||||
if (image) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: joinSections([
|
||||
header,
|
||||
// The note ends by suggesting raw bytes, which is no longer the
|
||||
// best answer once a readable rendering is attached.
|
||||
extraction.note.replace(/ Use \w+ with raw=true to get the bytes\./, ''),
|
||||
"Showing the instance's own rendered preview below, which is readable as a picture.",
|
||||
]),
|
||||
},
|
||||
{ type: 'image', data: image.bytes.toString('base64'), mimeType: image.mimeType },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return text(joinSections([header, extraction.note]));
|
||||
}
|
||||
|
||||
401
src/mcp/tools/filesystem.ts
Normal file
401
src/mcp/tools/filesystem.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import type { ServerContext } from '../../context.ts';
|
||||
import { formatBytes } from '../../core/extract.ts';
|
||||
import {
|
||||
areaInfo,
|
||||
compareNames,
|
||||
FILE_AREAS,
|
||||
FileManagerMarkupError,
|
||||
FsError,
|
||||
nameMatcher,
|
||||
type DirectoryRef,
|
||||
type FmFile,
|
||||
type FsNode,
|
||||
type WalkEntry,
|
||||
} from '../../core/legacy-files.ts';
|
||||
import { heading, joinSections } from '../../core/text.ts';
|
||||
import { renderFileContent } from './files.ts';
|
||||
import { failure, text, toToolError } from './result.ts';
|
||||
|
||||
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
||||
|
||||
/**
|
||||
* The "Dateien" file manager as filesystem tools: ls, tree, find, read.
|
||||
*
|
||||
* Deliberately separate from list_files / download_file, which read
|
||||
* files-storage — board, topic and task attachments. The two stores do not
|
||||
* overlap, and conflating them is how a course holding dozens of worksheets
|
||||
* came to be reported as having 0 files.
|
||||
*/
|
||||
|
||||
const AREA_NOTE =
|
||||
'The file manager ("Dateien") is separate from course pages and holds four areas: ' +
|
||||
'/my (Persönliche Dateien), /courses/<course name> (Kurs-Dateien), /teams/<team name> (Team-Dateien) and ' +
|
||||
'/shared (Geteilte Dateien). Many teachers keep their material only in Kurs-Dateien, so a course whose page ' +
|
||||
'looks empty often has its worksheets here.';
|
||||
|
||||
const PATH_NOTE =
|
||||
'Paths use the names shown in listings, e.g. "/courses/FIA24B - LF2 (Rh)/Handlungssituation". Names may ' +
|
||||
'contain "/" and still resolve; any segment may also be the id printed next to it, which is never ambiguous.';
|
||||
|
||||
export function registerFilesystemTools(server: McpServer, context: ServerContext): void {
|
||||
server.registerTool(
|
||||
'fs_list',
|
||||
{
|
||||
title: 'List a folder in the file manager',
|
||||
description:
|
||||
`Lists one folder of the Schulcloud file manager, like \`ls\`. ${AREA_NOTE} Start at "/" or ` +
|
||||
'"/courses" to see what exists. Given a file path instead, shows that file\'s details. ' +
|
||||
`${PATH_NOTE} Not for attachments on boards, topics or tasks — get_board, get_lesson and get_task list ` +
|
||||
'those, and download_file reads them.',
|
||||
inputSchema: {
|
||||
path: z.string().default('/').describe('Folder to list, e.g. "/", "/courses", "/courses/<course>/<folder>".'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ path }) => {
|
||||
try {
|
||||
const node = await context.files.resolve(path);
|
||||
if (node.kind === 'file') return text(describeFile(node));
|
||||
return text(await listDirectory(context, node));
|
||||
} catch (error) {
|
||||
return fsError(error, `list ${path}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'fs_tree',
|
||||
{
|
||||
title: 'Show a folder tree in the file manager',
|
||||
description:
|
||||
`Everything below a folder of the Schulcloud file manager, as an indented tree, like \`tree\`. ${AREA_NOTE} ` +
|
||||
'Use it to get an overview of a course\'s files in one call — "/courses/<course>" — or of all course ' +
|
||||
'files at a shallow depth. Each folder costs one page load, so the walk stops at `maxFolders` and says ' +
|
||||
'so; narrow the path or lower the depth rather than raising the limit. To look for a name, fs_find is ' +
|
||||
`cheaper. ${PATH_NOTE}`,
|
||||
inputSchema: {
|
||||
path: z.string().default('/').describe('Folder to start from.'),
|
||||
depth: z.number().int().min(1).max(8).default(3).describe('How many levels below the folder to show.'),
|
||||
maxFolders: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(400)
|
||||
.default(80)
|
||||
.describe('Stop after listing this many folders.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ path, depth, maxFolders }) => {
|
||||
try {
|
||||
const node = await context.files.resolve(path);
|
||||
if (node.kind === 'file') return text(describeFile(node));
|
||||
const result = await context.files.walk(node, { maxDepth: depth, maxDirectories: maxFolders });
|
||||
return text(renderTree(node, result.entries, { depth, maxFolders, ...result }));
|
||||
} catch (error) {
|
||||
return fsError(error, `walk ${path}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'fs_find',
|
||||
{
|
||||
title: 'Find files by name in the file manager',
|
||||
description:
|
||||
`Finds files and folders by name anywhere below a folder of the Schulcloud file manager, like \`find\`. ` +
|
||||
`${AREA_NOTE} Without wildcards it matches any part of the name, case-insensitively. With "*" or "?" the ` +
|
||||
'whole name must match, as with find -name — so "*.docx", or "*Erben*" for names containing Erben. Scope it with ' +
|
||||
'`path` (e.g. "/courses/<course>") whenever you know the course: searching all of /courses walks every ' +
|
||||
'folder of every course. This matches names only — to search inside documents use search, which ' +
|
||||
`covers file-manager files once they are indexed. ${PATH_NOTE}`,
|
||||
inputSchema: {
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('Part of the name ("Erbrecht"), or a whole-name pattern with * and ? ("*.docx", "*Erben*").'),
|
||||
path: z.string().default('/').describe('Folder to search below. Default: every area.'),
|
||||
type: z.enum(['any', 'file', 'folder']).default('any').describe('Only files, only folders, or both.'),
|
||||
maxFolders: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(600)
|
||||
.default(250)
|
||||
.describe('Stop after listing this many folders.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ name, path, type, maxFolders }) => {
|
||||
try {
|
||||
const node = await context.files.resolve(path);
|
||||
if (node.kind === 'file') return text(describeFile(node));
|
||||
const matcher = nameMatcher(name);
|
||||
const result = await context.files.walk(node, { maxDepth: 12, maxDirectories: maxFolders });
|
||||
const hits = result.entries
|
||||
.filter((entry) => (type === 'file' ? entry.file : type === 'folder' ? entry.directory : true))
|
||||
.filter((entry) => matcher((entry.file ?? entry.directory)?.name ?? ''))
|
||||
.sort((a, b) => compareNames(a.path, b.path));
|
||||
|
||||
const scope = `${result.visited} folder(s) searched`;
|
||||
const notes = [
|
||||
result.truncated
|
||||
? `_Stopped after ${maxFolders} folders, so there may be more matches. Narrow \`path\` to one course._`
|
||||
: undefined,
|
||||
failureNote(result.failures),
|
||||
];
|
||||
if (hits.length === 0) {
|
||||
return text(joinSections([`No names matching "${name}" below ${node.path} (${scope}).`, ...notes]));
|
||||
}
|
||||
return text(
|
||||
joinSections([
|
||||
heading(2, `${hits.length} match(es) for "${name}" below ${node.path}`),
|
||||
hits.map((entry) => entryLine(entry, { fullPath: true })).join('\n'),
|
||||
`_${scope}._ Read a file with fs_read, open a folder with fs_list.`,
|
||||
...notes,
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
return fsError(error, `search ${path}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'fs_read',
|
||||
{
|
||||
title: 'Read a file from the file manager',
|
||||
description:
|
||||
`Fetches one file from the Schulcloud file manager and returns its contents, like \`cat\`. ${AREA_NOTE} ` +
|
||||
'PDFs, Word, Excel, PowerPoint and OpenDocument files are extracted to text; images come back inline so ' +
|
||||
'you can look at them; anything else reports its type. Pass raw=true for base64 bytes. Give the path ' +
|
||||
'from a listing, or the file id and name. Not for board, topic or task attachments — use download_file ' +
|
||||
`for those. ${PATH_NOTE}`,
|
||||
inputSchema: {
|
||||
path: z.string().optional().describe('File path, e.g. "/courses/<course>/<folder>/Arbeitsblatt.pdf".'),
|
||||
fileId: z.string().optional().describe('File id from a listing, instead of a path.'),
|
||||
name: z.string().optional().describe('The file name, when giving fileId; used to recognise the format.'),
|
||||
raw: z.boolean().default(false).describe('Return base64-encoded bytes instead of extracted text.'),
|
||||
maxChars: z
|
||||
.number()
|
||||
.int()
|
||||
.min(500)
|
||||
.max(500_000)
|
||||
.optional()
|
||||
.describe('Override the character limit on extracted text.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ path, fileId, name, raw, maxChars }) => {
|
||||
try {
|
||||
let file: Pick<FmFile, 'id' | 'name'> & Partial<FmFile>;
|
||||
let where: string;
|
||||
if (path) {
|
||||
const node = await context.files.resolve(path);
|
||||
if (node.kind !== 'file') {
|
||||
return failure(`${node.path} is a folder, not a file. List it with fs_list, or use fs_tree.`);
|
||||
}
|
||||
file = node.file;
|
||||
where = node.path;
|
||||
} else if (fileId) {
|
||||
if (!/^[0-9a-f]{24}$/i.test(fileId)) return failure(`"${fileId}" is not a file id.`);
|
||||
file = { id: fileId, name: name?.trim() || fileId };
|
||||
where = `file \`${fileId}\``;
|
||||
} else {
|
||||
return failure('Give either `path` or `fileId`.');
|
||||
}
|
||||
|
||||
// The instance scans uploads; a file it rejected is not served.
|
||||
if (file.blocked) {
|
||||
return failure(`"${file.name}" was blocked by the instance's virus scanner and will not be downloaded.`);
|
||||
}
|
||||
|
||||
const downloaded = await context.files.download(file);
|
||||
const header = [
|
||||
heading(2, file.name),
|
||||
[
|
||||
`- Path: ${where}`,
|
||||
`- File id: \`${file.id}\``,
|
||||
`- Type: ${file.mimeType ?? downloaded.mimeType}`,
|
||||
`- Size: ${formatBytes(file.size ?? downloaded.bytes.length)}`,
|
||||
downloaded.truncated
|
||||
? `- **Download was capped at ${formatBytes(context.config.maxDownloadBytes)}; content is incomplete.**`
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
].join('\n\n');
|
||||
|
||||
return await renderFileContent(context, header, downloaded, {
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
raw,
|
||||
maxChars,
|
||||
});
|
||||
} catch (error) {
|
||||
return fsError(error, `read ${path ?? fileId}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function listDirectory(context: ServerContext, node: Extract<FsNode, { kind: 'directory' }>): Promise<string> {
|
||||
if (!node.ref.area) {
|
||||
return joinSections([
|
||||
heading(2, '/ — the file manager ("Dateien")'),
|
||||
FILE_AREAS.map((entry) => `- **/${entry.area}/** — ${entry.label}: ${entry.summary}`).join('\n'),
|
||||
'Open one with fs_list, e.g. path "/courses". A course\'s own files are under "/courses/<course name>".',
|
||||
]);
|
||||
}
|
||||
|
||||
const listing = await context.files.list(node.ref);
|
||||
const area = areaInfo(node.ref.area);
|
||||
const isOwnerList = (node.ref.area === 'courses' || node.ref.area === 'teams') && !node.ref.ownerId;
|
||||
const directories = [...listing.directories].sort((a, b) => compareNames(a.name, b.name));
|
||||
const files = [...listing.files].sort((a, b) => compareNames(a.name, b.name));
|
||||
|
||||
const title = heading(2, `${node.path} — ${area.label}`);
|
||||
if (directories.length === 0 && files.length === 0) {
|
||||
return joinSections([
|
||||
title,
|
||||
isOwnerList
|
||||
? `No ${node.ref.area === 'courses' ? 'courses' : 'teams'} with a file area.`
|
||||
: node.ref.area === 'shared'
|
||||
? 'Nothing has been shared with you.'
|
||||
: 'This folder is empty.',
|
||||
]);
|
||||
}
|
||||
|
||||
const lines = [
|
||||
...directories.map((directory) => `- **${directory.name}/** (\`${directory.id}\`)`),
|
||||
...files.map((file) => `- ${fileLine(file)}`),
|
||||
];
|
||||
const bytes = files.reduce((sum, file) => sum + file.size, 0);
|
||||
const summary = isOwnerList
|
||||
? `${directories.length} ${node.ref.area === 'courses' ? 'course' : 'team'}(s). Their files are inside; fs_tree with depth 2 shows which hold any.`
|
||||
: `${directories.length} folder(s), ${files.length} file(s)${files.length ? `, ${formatBytes(bytes)}` : ''}.`;
|
||||
|
||||
return joinSections([
|
||||
title,
|
||||
lines.join('\n'),
|
||||
summary,
|
||||
node.ref.area === 'shared' && directories.length > 0
|
||||
? '_Shared folders cannot be opened — the file manager has no route for them, not even in the browser._'
|
||||
: undefined,
|
||||
'Open a folder with fs_list (its name or id appended to this path), read a file with fs_read.',
|
||||
]);
|
||||
}
|
||||
|
||||
function describeFile(node: Extract<FsNode, { kind: 'file' }>): string {
|
||||
return joinSections([
|
||||
heading(2, node.file.name),
|
||||
[
|
||||
`- Path: ${node.path}`,
|
||||
`- File id: \`${node.file.id}\``,
|
||||
`- Type: ${node.file.mimeType ?? 'unknown'}`,
|
||||
`- Size: ${formatBytes(node.file.size)}`,
|
||||
node.file.blocked ? '- **Blocked by the instance virus scanner; it cannot be downloaded.**' : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
node.file.blocked ? undefined : 'Read it with fs_read.',
|
||||
]);
|
||||
}
|
||||
|
||||
function fileLine(file: FmFile): string {
|
||||
const type = file.mimeType ? `, ${file.mimeType}` : '';
|
||||
const blocked = file.blocked ? ' **[blocked by virus scan]**' : '';
|
||||
return `${file.name} — ${formatBytes(file.size)}${type} (\`${file.id}\`)${blocked}`;
|
||||
}
|
||||
|
||||
function entryLine(entry: WalkEntry, options: { fullPath: boolean }): string {
|
||||
const label = options.fullPath ? entry.path : (entry.file ?? entry.directory)?.name;
|
||||
if (entry.directory) return `- **${label}/** (\`${entry.directory.id}\`)`;
|
||||
if (entry.file) return `- ${fileLine({ ...entry.file, name: label ?? entry.file.name })}`;
|
||||
return `- ${label}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* An indented tree, built from each entry's parent rather than from sorting
|
||||
* path strings — see `FileManager.walk` for why that distinction matters.
|
||||
*/
|
||||
function renderTree(
|
||||
root: { path: string; ref: DirectoryRef },
|
||||
entries: WalkEntry[],
|
||||
info: { depth: number; maxFolders: number; visited: number; truncated: boolean; failures: { path: string; reason: string }[] },
|
||||
): string {
|
||||
const children = new Map<string, WalkEntry[]>();
|
||||
for (const entry of entries) {
|
||||
const list = children.get(entry.parentPath) ?? [];
|
||||
list.push(entry);
|
||||
children.set(entry.parentPath, list);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
let files = 0;
|
||||
let folders = 0;
|
||||
let bytes = 0;
|
||||
const visit = (path: string, indent: string) => {
|
||||
const kids = children.get(path) ?? [];
|
||||
// The areas under "/" keep their own order (personal, courses, teams,
|
||||
// shared); everything else sorts folders first, then by name.
|
||||
if (path !== '/') {
|
||||
kids.sort((a, b) => {
|
||||
if (Boolean(a.directory) !== Boolean(b.directory)) return a.directory ? -1 : 1;
|
||||
return compareNames(a.path, b.path);
|
||||
});
|
||||
}
|
||||
for (const kid of kids) {
|
||||
if (kid.directory) {
|
||||
folders++;
|
||||
// An area's "id" is its slug, not an id anything accepts; leave it out.
|
||||
const id = /^[0-9a-f]{24}$/i.test(kid.directory.id) ? ` \`${kid.directory.id}\`` : '';
|
||||
lines.push(`${indent}${kid.directory.name}/${id}`);
|
||||
visit(kid.path, `${indent} `);
|
||||
} else if (kid.file) {
|
||||
files++;
|
||||
bytes += kid.file.size;
|
||||
const blocked = kid.file.blocked ? ' [blocked]' : '';
|
||||
lines.push(`${indent}${kid.file.name} (${formatBytes(kid.file.size)}) \`${kid.file.id}\`${blocked}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(root.path, '');
|
||||
|
||||
const area = root.ref.area ? ` — ${areaInfo(root.ref.area).label}` : '';
|
||||
if (lines.length === 0) {
|
||||
return joinSections([heading(2, `${root.path}${area}`), 'Nothing below this folder.', failureNote(info.failures)]);
|
||||
}
|
||||
|
||||
return joinSections([
|
||||
heading(2, `${root.path}${area}`),
|
||||
['```', ...lines, '```'].join('\n'),
|
||||
`${folders} folder(s), ${files} file(s), ${formatBytes(bytes)} — ${info.visited} folder(s) listed, ${info.depth} level(s) deep.`,
|
||||
info.truncated
|
||||
? `_Stopped after listing ${info.maxFolders} folders; the tree is incomplete. Start deeper, e.g. at one course._`
|
||||
: undefined,
|
||||
failureNote(info.failures),
|
||||
'Read a file with fs_read (path = this folder plus the names above).',
|
||||
]);
|
||||
}
|
||||
|
||||
function failureNote(failures: { path: string; reason: string }[]): string | undefined {
|
||||
if (failures.length === 0) return undefined;
|
||||
const shown = failures.slice(0, 5).map((entry) => `${entry.path} (${entry.reason})`).join('; ');
|
||||
return `_Could not list ${failures.length} folder(s): ${shown}${failures.length > 5 ? '; …' : ''}._`;
|
||||
}
|
||||
|
||||
function fsError(error: unknown, action: string): CallToolResult {
|
||||
if (error instanceof FsError) return failure(error.message);
|
||||
if (error instanceof FileManagerMarkupError) {
|
||||
return failure(
|
||||
`Could not ${action}: the file manager page did not look like a file listing. Either the session is no ` +
|
||||
'longer accepted (check whoami) or the page markup changed.',
|
||||
);
|
||||
}
|
||||
return toToolError(error, action);
|
||||
}
|
||||
|
||||
93
src/mcp/tools/h5p.ts
Normal file
93
src/mcp/tools/h5p.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The H5P tool: a quiz in full, rather than one question at a time.
|
||||
*
|
||||
* The board tells you an exercise exists and how many questions it holds; this
|
||||
* prints them. Kept out of the board on purpose — twenty questions with their
|
||||
* options would bury everything else on it.
|
||||
*/
|
||||
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import type { ServerContext } from '../../context.ts';
|
||||
import { heading, joinSections } from '../../core/text.ts';
|
||||
import { readH5pContent, type H5pContent, type H5pQuestion } from '../../core/h5p.ts';
|
||||
import { text, toToolError } from './result.ts';
|
||||
|
||||
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
||||
|
||||
export function registerH5pTools(server: McpServer, context: ServerContext): void {
|
||||
server.registerTool(
|
||||
'get_h5p',
|
||||
{
|
||||
title: 'Get H5P exercise',
|
||||
description:
|
||||
'An interactive exercise in full: every question, every option, and which of them is correct. This ' +
|
||||
'is how to read a quiz ("Quiz", "Test", "Übung") that a teacher built into a board — Schulcloud has ' +
|
||||
'no quiz of its own, so these are H5P elements, and get_board lists them with the content id this ' +
|
||||
'takes. The player shows one question at a time; this returns all of them at once, so there is ' +
|
||||
'nothing to step through. Pass solutions=false to get the questions without the answers, for asking ' +
|
||||
'the user them one by one.',
|
||||
inputSchema: {
|
||||
contentId: z
|
||||
.string()
|
||||
.describe('H5P content id, as get_board prints it for an H5P element (not the element id).'),
|
||||
solutions: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe('Mark the correct options. Turn off to quiz the user without giving the answers away.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ contentId, solutions }) => {
|
||||
try {
|
||||
return text(formatH5p(await readH5pContent(context.client, contentId), solutions));
|
||||
} catch (error) {
|
||||
return toToolError(error, `read H5P content ${contentId}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function formatH5p(content: H5pContent, solutions: boolean): string {
|
||||
const facts = [
|
||||
`- Content id: \`${content.contentId}\``,
|
||||
`- Type: ${content.library}`,
|
||||
`- Questions: ${content.questions.length}`,
|
||||
content.passPercentage !== undefined ? `- Pass mark: ${content.passPercentage}%` : undefined,
|
||||
solutions ? undefined : '- _Solutions withheld: call again with solutions=true to see them._',
|
||||
].filter(Boolean);
|
||||
|
||||
return joinSections([
|
||||
heading(2, content.title),
|
||||
facts.join('\n'),
|
||||
content.intro,
|
||||
...content.questions.map((question, index) => formatQuestion(question, index + 1, solutions)),
|
||||
content.questions.length === 0 && content.unmodelled.length === 0
|
||||
? '_This exercise holds no questions this server can read._'
|
||||
: undefined,
|
||||
// Never silently dropped: an exercise type this server does not model
|
||||
// still has its text reported, labelled for what it is.
|
||||
content.unmodelled.length > 0
|
||||
? joinSections([
|
||||
heading(3, 'Parts this server cannot model'),
|
||||
'_Read as plain text, so the structure of these is lost:_',
|
||||
content.unmodelled.map((part) => `- ${part}`).join('\n'),
|
||||
])
|
||||
: undefined,
|
||||
]);
|
||||
}
|
||||
|
||||
function formatQuestion(question: H5pQuestion, number: number, solutions: boolean): string {
|
||||
const hint = question.multiple ? ', several correct' : '';
|
||||
const label = `${number}. ${question.text || '(no question text)'}`;
|
||||
const options = question.answers.map((answer) => {
|
||||
const tip = answer.tip ? ` _(Hinweis: ${answer.tip})_` : '';
|
||||
if (!solutions || answer.correct === undefined) return `- ${answer.text}${tip}`;
|
||||
return answer.correct ? `- **${answer.text}** ✔${tip}` : `- ${answer.text}${tip}`;
|
||||
});
|
||||
return joinSections([
|
||||
heading(3, `${label} _(${question.kind}${hint})_`),
|
||||
question.body,
|
||||
options.length > 0 ? options.join('\n') : '_(no options)_',
|
||||
]);
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import { failure, text, toToolError } from './result.ts';
|
||||
|
||||
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
||||
|
||||
/** How long refresh_index waits for a crawl before answering that it is still running. */
|
||||
const REFRESH_WAIT_MS = 50_000;
|
||||
|
||||
export function registerIndexTools(server: McpServer, context: ServerContext): void {
|
||||
server.registerTool(
|
||||
'refresh_index',
|
||||
@@ -14,8 +17,10 @@ export function registerIndexTools(server: McpServer, context: ServerContext): v
|
||||
description:
|
||||
'Re-reads Schulcloud and updates the local index, so search and what_changed see the newest state. ' +
|
||||
'Pass a courseId when you know which course changed — that costs a handful of requests, whereas a ' +
|
||||
'full re-crawl reads every course and takes up to a minute. Use it when the user says they just ' +
|
||||
'uploaded or were given something and search cannot find it yet.',
|
||||
'full re-crawl reads every course, every course\'s file-manager folders and every new file, and takes ' +
|
||||
'minutes. A crawl that outlasts about 50 seconds keeps running in the background: this returns, and ' +
|
||||
'index_status says when it is done. Use it when the user says they just uploaded or were given ' +
|
||||
'something and search cannot find it yet.',
|
||||
inputSchema: {
|
||||
courseId: z
|
||||
.string()
|
||||
@@ -33,14 +38,37 @@ export function registerIndexTools(server: McpServer, context: ServerContext): v
|
||||
async ({ courseId, force }) => {
|
||||
if (!context.indexer) return failure(indexUnavailable('refresh_index'));
|
||||
try {
|
||||
const result = await context.indexer.refresh(courseId ?? 'full', { force });
|
||||
const { run } = context.indexer.start(courseId ?? 'full', { force });
|
||||
// A tool call must not wait out a long crawl: MCP clients time calls
|
||||
// out, and a first crawl that downloads every course file runs for many
|
||||
// minutes. The crawl carries on either way; the indexer records the
|
||||
// outcome for index_status.
|
||||
const outcome = await Promise.race([
|
||||
run.then((result) => ({ done: true as const, result })),
|
||||
new Promise<{ done: false }>((resolve) => setTimeout(() => resolve({ done: false }), REFRESH_WAIT_MS)),
|
||||
]);
|
||||
if (!outcome.done) {
|
||||
run.catch(() => {});
|
||||
return text(
|
||||
joinSections([
|
||||
heading(2, 'Re-crawl running in the background'),
|
||||
`Still crawling ${courseId ? `course ${courseId}` : 'all courses'} after ${REFRESH_WAIT_MS / 1000}s. ` +
|
||||
'It continues on the server; call index_status in a minute or two to see when it has finished. ' +
|
||||
'Until then, search answers from the previous crawl.',
|
||||
]),
|
||||
);
|
||||
}
|
||||
const result = outcome.result;
|
||||
return text(
|
||||
joinSections([
|
||||
heading(2, result.joined ? 'Joined a re-crawl already in progress' : 'Re-crawl complete'),
|
||||
[
|
||||
`- Scope: ${result.scope === 'full' ? 'all courses' : `course ${result.scope}`}`,
|
||||
`- Generation: ${result.crawlId}`,
|
||||
`- Courses: ${result.courses}, files: ${result.files}`,
|
||||
`- Courses: ${result.courses}${result.rooms > 0 ? `, rooms: ${result.rooms}` : ''}, files: ${result.files}`,
|
||||
result.notes > 0 || result.lessons > 0
|
||||
? `- Own notes: ${result.notes}, class-register lessons: ${result.lessons}`
|
||||
: undefined,
|
||||
`- Newly mirrored: ${result.mirrored}, text extracted: ${result.extracted}, skipped: ${result.skipped}`,
|
||||
`- Took ${(result.durationMs / 1000).toFixed(1)}s`,
|
||||
result.failures.length > 0
|
||||
@@ -67,13 +95,15 @@ export function registerIndexTools(server: McpServer, context: ServerContext): v
|
||||
description:
|
||||
'Lists boards, cards, files, lessons and tasks that appeared, changed or disappeared since a point in ' +
|
||||
'time. The Schulcloud API has no "changed since" filter of any kind, so this compares stored crawls — ' +
|
||||
'meaning it can only see back as far as the index goes. This is the tool for "what is new this week?".',
|
||||
'meaning it can only see back as far as the index goes. This is the tool for "what is new this week?". ' +
|
||||
'It also covers the user\'s own notes and the WebUntis class register, so "what has happened since ' +
|
||||
'Monday" includes the lessons that were logged and the notes that were written.',
|
||||
inputSchema: {
|
||||
since: z
|
||||
.string()
|
||||
.describe('An ISO date/time, or a generation id from refresh_index. e.g. "2026-09-10".'),
|
||||
kinds: z
|
||||
.array(z.enum(['course', 'board', 'lesson', 'task', 'file']))
|
||||
.array(z.enum(['course', 'room', 'board', 'lesson', 'task', 'file', 'submission', 'note', 'untis']))
|
||||
.optional()
|
||||
.describe('Restrict to certain kinds of thing. Omit for all.'),
|
||||
limit: z.number().int().min(1).max(200).default(50).describe('Maximum entries per section.'),
|
||||
@@ -154,7 +184,14 @@ export function registerIndexTools(server: McpServer, context: ServerContext): v
|
||||
const stats = await context.store.stats();
|
||||
const status = context.indexer?.status();
|
||||
if (stats.crawlId === undefined) {
|
||||
return text('The index is empty. Run refresh_index to populate it.');
|
||||
// The first crawl is the long one; "run refresh_index" while it is
|
||||
// already running would only send the caller round in a circle.
|
||||
return text(
|
||||
status?.running
|
||||
? `The first crawl is running now (started ${formatDate(status.startedAt)}). The index fills when ` +
|
||||
'it finishes; the fs_* tools and the live tools work in the meantime.'
|
||||
: 'The index is empty. Run refresh_index to populate it.',
|
||||
);
|
||||
}
|
||||
const age = stats.crawledAt ? Date.now() - new Date(stats.crawledAt).getTime() : undefined;
|
||||
return text(
|
||||
|
||||
268
src/mcp/tools/notes.ts
Normal file
268
src/mcp/tools/notes.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import type { ServerContext } from '../../context.ts';
|
||||
import { germanDay, isCalendarDate, schoolToday } from '../../core/dates.ts';
|
||||
import {
|
||||
filterNotes,
|
||||
NoteNotFound,
|
||||
noteSections,
|
||||
noteSubjects,
|
||||
normalizeRelative,
|
||||
readNoteAt,
|
||||
readNotes,
|
||||
writeNote,
|
||||
type NoteDoc,
|
||||
} from '../../core/notes.ts';
|
||||
import { heading, joinSections, matchesAll, tokenize } from '../../core/text.ts';
|
||||
import { failure, text, toToolError } from './result.ts';
|
||||
|
||||
/**
|
||||
* The user's own lesson notes.
|
||||
*
|
||||
* Registered only when NOTES_DIR is set, on the same principle as the untis_*
|
||||
* tools: a note tool with nowhere to read from can only ever fail, and a model
|
||||
* offered one will keep trying it.
|
||||
*
|
||||
* These are the only tools in this server that write anything, and what they
|
||||
* write is the user's own notes directory — never Schulcloud, which stays
|
||||
* read-only in the strict sense the invariant in CLAUDE.md describes. The write
|
||||
* is bounded by the same two functions the file mirror uses: every path
|
||||
* component is reduced by `safeComponent` and the result is checked by
|
||||
* `resolveWithin`, so a title of `../../.ssh/authorized_keys` becomes a
|
||||
* filename and not a path.
|
||||
*/
|
||||
|
||||
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
||||
|
||||
/** Notes listed before the tool starts summarising instead of listing. */
|
||||
const MAX_LISTED = 200;
|
||||
|
||||
const dateArgument = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD.')
|
||||
.describe('A date as YYYY-MM-DD.');
|
||||
|
||||
export function registerNoteTools(server: McpServer, context: ServerContext): void {
|
||||
const root = context.config.notesDir;
|
||||
if (!root) return;
|
||||
|
||||
server.registerTool(
|
||||
'list_notes',
|
||||
{
|
||||
title: 'My lesson notes',
|
||||
description:
|
||||
"The user's own notes from lessons — what they wrote down themselves, which is neither in Schulcloud " +
|
||||
'nor in WebUntis and is often the only record of what a teacher actually said. **Read these before ' +
|
||||
'answering anything about what was covered in class, and before preparing for a test**: they say what ' +
|
||||
'was emphasised, which the uploaded material does not. Filter by subject ("Deutsch", "LF07") or by ' +
|
||||
'date to get the lessons around a topic — a note is often a whole school day with a heading per ' +
|
||||
'lesson, so the subject filter looks at those headings too. Returns titles and a preview; get_note ' +
|
||||
'opens one. search finds notes by their contents as well, and names the lesson it matched in.',
|
||||
inputSchema: {
|
||||
subject: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Only notes for this subject, matched as a fragment. The user\'s own wording, not a course id.'),
|
||||
since: dateArgument.optional().describe('Only notes from this day onwards.'),
|
||||
until: dateArgument.optional().describe('Only notes up to and including this day.'),
|
||||
query: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Only notes whose title, subject or tags contain every word given. For full text, use search.'),
|
||||
limit: z.number().int().min(1).max(MAX_LISTED).default(50).describe('Maximum notes to list.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ subject, since, until, query, limit }) => {
|
||||
const unreal = [since, until].filter((value): value is string => Boolean(value) && !isCalendarDate(value!));
|
||||
if (unreal.length > 0) return failure(`Not a date in the calendar: ${unreal.join(', ')}. Use YYYY-MM-DD.`);
|
||||
try {
|
||||
const all = await readNotes(root);
|
||||
if (all.length === 0) return text(emptyStore(root, context.config.notesWritable));
|
||||
|
||||
const terms = query ? tokenize(query) : [];
|
||||
const matched = filterNotes(all, { subject, since, until }).filter(
|
||||
(note) =>
|
||||
terms.length === 0 ||
|
||||
matchesAll([note.title, ...noteSubjects(note), note.tags.join(' ')].join(' '), terms),
|
||||
);
|
||||
if (matched.length === 0) {
|
||||
return text(
|
||||
`None of the ${all.length} note(s) match${describeFilter({ subject, since, until, query })}. ` +
|
||||
'Drop a filter, or use search to look inside the text.',
|
||||
);
|
||||
}
|
||||
return text(
|
||||
joinSections([
|
||||
heading(2, `Notizen (${Math.min(limit, matched.length)} of ${matched.length})`),
|
||||
matched.slice(0, limit).map(listLine).join('\n'),
|
||||
matched.length > limit ? `_${matched.length - limit} more — narrow it down with subject or since._` : undefined,
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
return toToolError(error, 'read the notes directory');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_note',
|
||||
{
|
||||
title: 'Read one note',
|
||||
description:
|
||||
'The full text of one of the user\'s own notes, by the path list_notes and search print. Quote from it ' +
|
||||
'the way you would quote a course file — it is a primary source for what happened in the lesson.',
|
||||
inputSchema: {
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('The note\'s path, e.g. "Deutsch/2026-09-15 Erörterung.md", exactly as it was listed.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
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 {
|
||||
return text(renderNote(await readNoteAt(root, wanted)));
|
||||
} catch (error) {
|
||||
if (error instanceof NoteNotFound) {
|
||||
return failure(
|
||||
`There is no note at "${wanted}". Paths come from list_notes or search and include the folder and ` +
|
||||
'the .md ending.',
|
||||
);
|
||||
}
|
||||
return toToolError(error, `read the note "${wanted}"`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!context.config.notesWritable) return;
|
||||
|
||||
server.registerTool(
|
||||
'add_note',
|
||||
{
|
||||
title: 'Write a lesson note',
|
||||
description:
|
||||
'Saves a note into the user\'s own notes, so it is there next time — during a lesson ("halte fest, ' +
|
||||
'dass …"), or when writing up what was just discussed. Give the subject as the user says it ' +
|
||||
'("Deutsch", "LF07") and the day the lesson was on; both are what makes the note findable later. ' +
|
||||
'Pass append=true to add to the note already written for that subject and day rather than starting a ' +
|
||||
'second one — that is the right choice during a lesson. This writes **only** to the notes directory; ' +
|
||||
'it cannot change anything in Schulcloud or WebUntis. Do not use it to store things the user did not ' +
|
||||
'ask to keep.',
|
||||
inputSchema: {
|
||||
title: z.string().min(1).max(200).describe('A short title — the topic of the lesson, not a sentence.'),
|
||||
text: z.string().min(1).describe('The note itself, as Markdown. Write it in the language the user used.'),
|
||||
subject: z
|
||||
.string()
|
||||
.max(80)
|
||||
.optional()
|
||||
.describe('Subject as the user names it, e.g. "Deutsch" or "LF07". Becomes the folder.'),
|
||||
date: dateArgument.optional().describe('The day of the lesson. Defaults to today.'),
|
||||
tags: z.array(z.string().max(40)).max(12).optional().describe('Optional keywords, e.g. ["klausur"].'),
|
||||
courseId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Schulcloud course id, when it is known — it links the note to the course in search.'),
|
||||
append: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe('Add to an existing note for that subject and day instead of creating another one.'),
|
||||
},
|
||||
// Writes — to the notes directory, and to nothing else.
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
},
|
||||
async ({ title, text: body, subject, date, tags, courseId, append }) => {
|
||||
if (date && !isCalendarDate(date)) return failure(`Not a date in the calendar: ${date}. Use YYYY-MM-DD.`);
|
||||
try {
|
||||
const { note, appended } = await writeNote(root, {
|
||||
title,
|
||||
text: body,
|
||||
date: date ?? schoolToday(),
|
||||
...(subject ? { subject } : {}),
|
||||
...(courseId ? { courseId } : {}),
|
||||
...(tags && tags.length > 0 ? { tags } : {}),
|
||||
source: 'add_note',
|
||||
append,
|
||||
});
|
||||
return text(
|
||||
joinSections([
|
||||
`${appended ? 'Added to' : 'Saved'} **${note.title}** — \`${note.path}\``,
|
||||
'_It is searchable after the next refresh_index; get_note reads it now._',
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
return toToolError(error, `save the note "${title}"`);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// --- formatting ----------------------------------------------------------
|
||||
|
||||
function listLine(note: NoteDoc): string {
|
||||
const when = note.date ? germanDay(note.date) : 'ohne Datum';
|
||||
const subjects = noteSubjects(note);
|
||||
const where = subjects.length > 0 ? ` · ${subjects.join(', ')}` : '';
|
||||
const tags = note.tags.length > 0 ? ` · ${note.tags.map((tag) => `#${tag}`).join(' ')}` : '';
|
||||
// For a day's note the lessons are the useful preview; for a single piece of
|
||||
// prose the first line is.
|
||||
const sections = noteSections(note);
|
||||
const preview = sections.length > 0 ? `${sections.length} Stunde(n)` : firstLine(note.text);
|
||||
return [`- **${note.title}** — ${when}${where}${tags} \`${note.path}\``, preview ? ` ${preview}` : undefined]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function renderNote(note: NoteDoc): string {
|
||||
const subjects = noteSubjects(note);
|
||||
const facts = [
|
||||
note.date ? `Datum: ${germanDay(note.date)}` : undefined,
|
||||
subjects.length > 0 ? `${subjects.length > 1 ? 'Fächer' : 'Fach'}: ${subjects.join(', ')}` : undefined,
|
||||
note.tags.length > 0 ? `Tags: ${note.tags.join(', ')}` : undefined,
|
||||
note.courseId ? `Kurs: \`${note.courseId}\`` : undefined,
|
||||
].filter(Boolean);
|
||||
return joinSections([
|
||||
heading(2, note.title),
|
||||
facts.length > 0 ? `_${facts.join(' · ')}_` : undefined,
|
||||
note.text || '_This note is empty._',
|
||||
`_Own note: \`${note.path}\`_`,
|
||||
]);
|
||||
}
|
||||
|
||||
function firstLine(body: string): string | undefined {
|
||||
const line = body
|
||||
.split('\n')
|
||||
.map((entry) => entry.replace(/^#+\s*/, '').trim())
|
||||
.find((entry) => entry.length > 0);
|
||||
if (!line) return undefined;
|
||||
return line.length > 160 ? `${line.slice(0, 157)}…` : line;
|
||||
}
|
||||
|
||||
function describeFilter(filter: { subject?: string; since?: string; until?: string; query?: string }): string {
|
||||
const parts = [
|
||||
filter.subject ? `subject "${filter.subject}"` : undefined,
|
||||
filter.query ? `"${filter.query}"` : undefined,
|
||||
filter.since ? `from ${germanDay(filter.since)}` : undefined,
|
||||
filter.until ? `to ${germanDay(filter.until)}` : undefined,
|
||||
].filter(Boolean);
|
||||
return parts.length > 0 ? ` ${parts.join(', ')}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The empty case, which is the normal one on a fresh install.
|
||||
*
|
||||
* It says where the directory is because the usual next step is to put files
|
||||
* there by hand or with the import script, not to call a tool.
|
||||
*/
|
||||
function emptyStore(root: string, writable: boolean): string {
|
||||
return joinSections([
|
||||
`There are no notes yet. The notes directory is \`${root}\`.`,
|
||||
writable
|
||||
? 'Notes are Markdown files; add_note writes one, and anything dropped in that directory is picked up too.'
|
||||
: 'This server was started with NOTES_READONLY, so notes have to be put there by hand or synced in.',
|
||||
]);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import type { ServerContext } from '../../context.ts';
|
||||
import { decodeClaims } from '../../core/session-token.ts';
|
||||
import { dueLabel, formatDate, heading, htmlToText, joinSections } from '../../core/text.ts';
|
||||
import type { CourseMetadata, TaskContent } from '../../core/types.ts';
|
||||
import { text, toToolError } from './result.ts';
|
||||
@@ -20,6 +21,8 @@ export function registerOverviewTools(server: McpServer, context: ServerContext)
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async () => {
|
||||
// Never fails, so the WebUntis half survives a dead Schulcloud session.
|
||||
const untis = await untisLine(context);
|
||||
try {
|
||||
const me = await context.me();
|
||||
return text(
|
||||
@@ -31,11 +34,20 @@ export function registerOverviewTools(server: McpServer, context: ServerContext)
|
||||
`- Roles: ${me.roles.map((role) => role.name).join(', ') || 'none'}`,
|
||||
`- Instance: ${context.config.baseUrl}`,
|
||||
`- Permissions: ${me.permissions.length}`,
|
||||
].join('\n'),
|
||||
tokenExpiryLine(context.config.jwt),
|
||||
untis,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
return toToolError(error, 'read the current user');
|
||||
const problem = toToolError(error, 'read the current user');
|
||||
// One identity tool now answers for two systems: an expired Schulcloud
|
||||
// token must not hide a working WebUntis key, or "is the server
|
||||
// reachable?" gets a misleadingly total no.
|
||||
if (!untis) return problem;
|
||||
return { ...problem, content: [...problem.content, { type: 'text' as const, text: untis }] };
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -184,6 +196,38 @@ export function registerOverviewTools(server: McpServer, context: ServerContext)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The WebUntis side of the account, when configured.
|
||||
*
|
||||
* Never throws: whoami is the connectivity check, so a rejected Untis key has
|
||||
* to be reported *in* the answer rather than replace it — the Schulcloud half
|
||||
* of the report is still true and still useful.
|
||||
*/
|
||||
async function untisLine(context: ServerContext): Promise<string | undefined> {
|
||||
if (!context.untis) return undefined;
|
||||
try {
|
||||
const me = await context.untis.identity();
|
||||
return (
|
||||
`- WebUntis: ${me.displayName} (${me.elementType.toLowerCase()}) at ${me.schoolName}` +
|
||||
` — timetable via untis_timetable`
|
||||
);
|
||||
} catch (error) {
|
||||
return `- WebUntis: **not reachable** — ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When the server's Schulcloud token runs out. Only a person can renew it, so
|
||||
* the week before is worth saying out loud wherever the account is shown.
|
||||
*/
|
||||
function tokenExpiryLine(jwt: string): string | undefined {
|
||||
const exp = decodeClaims(jwt)?.exp;
|
||||
if (exp === undefined) return undefined;
|
||||
const days = Math.floor((exp * 1000 - Date.now()) / 86_400_000);
|
||||
const renew = days <= 7 ? ' — **renew it soon** with `schulcloud token set` or the server\'s /token page' : '';
|
||||
return `- Server's Schulcloud token: expires ${formatDate(new Date(exp * 1000).toISOString())} (${days} day(s) left)${renew}`;
|
||||
}
|
||||
|
||||
function isCurrentlyRunning(course: CourseMetadata): boolean {
|
||||
const now = Date.now();
|
||||
const start = course.startDate ? new Date(course.startDate).getTime() : undefined;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user