Compare commits
5 Commits
feat/core-
...
feat/local
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a168e37e5 | ||
|
|
521c21f7ae | ||
|
|
0ae9198428 | ||
|
|
a3aded110c | ||
|
|
290b352b07 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,3 +9,6 @@ vendor/
|
|||||||
# Local scratch
|
# Local scratch
|
||||||
tmp/
|
tmp/
|
||||||
files.zip
|
files.zip
|
||||||
|
|
||||||
|
# Ids of the fixture the teacher simulation currently has in place.
|
||||||
|
local-instance/.simulate-teacher.json
|
||||||
|
|||||||
14
CLAUDE.md
14
CLAUDE.md
@@ -123,6 +123,20 @@ 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
|
- **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 —
|
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`.
|
which is how the 20-id limit went unnoticed. They go into `Snapshot.failures`.
|
||||||
|
- **`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,
|
- **Submissions: only `GET /submissions/status/task/{taskId}` exists.** No list,
|
||||||
no fetch-by-id, and the payload has no submitted text, grade comment or
|
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
|
graded-at — `/api/v1`, which had them, is not served here. Don't imply absent
|
||||||
|
|||||||
36
docs/API.md
36
docs/API.md
@@ -173,6 +173,31 @@ clearest case in this API of live behaviour diverging from upstream source.
|
|||||||
instance configuration, including the session timeouts and feature flags. Handy
|
instance configuration, including the session timeouts and feature flags. Handy
|
||||||
for checking deployed settings without a token.
|
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
|
**`Content-Disposition` on downloads is malformed.** It comes back as
|
||||||
`attachment;; filename="…"` — note the doubled semicolon — and the filename is
|
`attachment;; filename="…"` — note the doubled semicolon — and the filename is
|
||||||
percent-encoded inside the quotes. Parse defensively.
|
percent-encoded inside the quotes. Parse defensively.
|
||||||
@@ -183,9 +208,14 @@ From `ContentElementType` in `schulcloud-server`, all seen live except where
|
|||||||
noted: `richText`, `file`, `fileFolder`, `link`, `drawing`,
|
noted: `richText`, `file`, `fileFolder`, `link`, `drawing`,
|
||||||
`collaborativeTextEditor`, `externalTool`, `videoConference`, `h5p`, `deleted`.
|
`collaborativeTextEditor`, `externalTool`, `videoConference`, `h5p`, `deleted`.
|
||||||
|
|
||||||
Collaborative text editor contents are **not** retrievable through the API —
|
Collaborative text editor elements come back with `content: {}` — no pad id, no
|
||||||
`GET /api/v3/collaborative-text-editor/{parentType}/{parentId}` returns a URL to
|
url, nothing. `GET /api/v3/collaborative-text-editor/content-element/{elementId}`
|
||||||
the Etherpad-style editor, not the document text.
|
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.
|
||||||
|
|
||||||
## Re-verifying after an upstream release
|
## Re-verifying after an upstream release
|
||||||
|
|
||||||
|
|||||||
251
local-instance/README.md
Normal file
251
local-instance/README.md
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
# 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
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
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 # 39 checks against the local instance
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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.
|
||||||
296
local-instance/docker-compose.yml
Normal file
296
local-instance/docker-compose.yml
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
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]
|
||||||
|
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
|
||||||
585
local-instance/proxy/nginx.conf
Normal file
585
local-instance/proxy/nginx.conf
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
# 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;
|
||||||
|
|
||||||
|
# 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
190
local-instance/scripts/gen-proxy-conf.py
Normal file
190
local-instance/scripts/gen-proxy-conf.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
#!/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;
|
||||||
|
|
||||||
|
# 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()
|
||||||
30
local-instance/scripts/mcp-env.sh
Executable file
30
local-instance/scripts/mcp-env.sh
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/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"])')
|
||||||
|
|
||||||
|
cat <<ENV
|
||||||
|
export TSC_URL=$URL
|
||||||
|
export TSC_JWT_COOKIE=$token
|
||||||
|
export MCP_AUTH_TOKEN=local-instance-token
|
||||||
|
ENV
|
||||||
19
local-instance/scripts/minio-init.sh
Executable file
19
local-instance/scripts/minio-init.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/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
|
||||||
|
do
|
||||||
|
mc mb --ignore-existing "local/$bucket"
|
||||||
|
done
|
||||||
|
|
||||||
|
mc ls local
|
||||||
106
local-instance/scripts/seed.sh
Executable file
106
local-instance/scripts/seed.sh
Executable file
@@ -0,0 +1,106 @@
|
|||||||
|
#!/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',
|
||||||
|
endpointUrl: 'http://minio:9000',
|
||||||
|
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
|
||||||
356
local-instance/scripts/simulate-teacher.mjs
Executable file
356
local-instance/scripts/simulate-teacher.mjs
Executable file
@@ -0,0 +1,356 @@
|
|||||||
|
#!/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
|
||||||
|
*
|
||||||
|
* 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('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);
|
||||||
|
console.log(`\nstate written to ${STATE}`);
|
||||||
|
summary(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 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');
|
||||||
|
|
||||||
|
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}`)],
|
||||||
|
['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', () => v3('DELETE', `/rooms/${s.roomId}`)],
|
||||||
|
['course', () => v1('DELETE', `/courses/${s.courseId}`)],
|
||||||
|
];
|
||||||
|
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 === 'show') summary(loadState());
|
||||||
|
else {
|
||||||
|
console.error(`unknown phase ${phase}; expected create | update | delete | show`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
@@ -106,30 +106,76 @@ const taskId = tasks.text.match(/\(`([0-9a-f]{24})`\)/)?.[1];
|
|||||||
check('list_tasks finished', !(await call('list_tasks', { scope: 'finished' })).isError);
|
check('list_tasks finished', !(await call('list_tasks', { scope: 'finished' })).isError);
|
||||||
check('list_news', !(await call('list_news')).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;
|
let boardId, fileId, lessonId, courseWithBoard;
|
||||||
|
const boardIds = [];
|
||||||
|
const topicsWithTasks = [];
|
||||||
for (const id of courseIds) {
|
for (const id of courseIds) {
|
||||||
const course = await call('get_course', { courseId: id });
|
const course = await call('get_course', { courseId: id });
|
||||||
if (course.isError) continue;
|
if (course.isError) continue;
|
||||||
courseWithBoard ??= id;
|
courseWithBoard ??= id;
|
||||||
const b = course.text.match(/### Boards[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
|
const boardsSection = course.text.match(/### Boards[\s\S]*?(?=\n### |$)/)?.[0] ?? '';
|
||||||
const l = course.text.match(/### Topics[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
|
for (const m of boardsSection.matchAll(/\(`([0-9a-f]{24})`\)/g)) boardIds.push(m[1]);
|
||||||
lessonId ??= l;
|
lessonId ??= course.text.match(/### Topics[\s\S]*?\(`([0-9a-f]{24})`\)/)?.[1];
|
||||||
if (b && !boardId) boardId = b;
|
// A topic that reports tasks is the interesting one: those tasks are not task
|
||||||
if (boardId && lessonId) break;
|
// 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('get_course', Boolean(courseWithBoard), `first usable course ${courseWithBoard}`);
|
||||||
check('found a column board', Boolean(boardId), boardId);
|
check('found a column board', boardIds.length > 0, `${boardIds.length} board(s)`);
|
||||||
|
|
||||||
if (boardId) {
|
let board, drafts = 0;
|
||||||
const board = await call('get_board', { boardId });
|
for (const id of boardIds) {
|
||||||
check('get_board', !board.isError && /Board id:/.test(board.text));
|
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];
|
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 resolved attachments', Boolean(fileId), fileId ?? 'no files on this board');
|
||||||
check('get_board includeFiles=false', !(await call('get_board', { boardId, includeFiles: false })).isError);
|
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);
|
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) {
|
if (taskId) {
|
||||||
const task = await call('get_task', { taskId });
|
const task = await call('get_task', { taskId });
|
||||||
check('get_task', !task.isError && /Task id:/.test(task.text), taskId);
|
check('get_task', !task.isError && /Task id:/.test(task.text), taskId);
|
||||||
@@ -180,13 +226,25 @@ check(
|
|||||||
hasIndex ? !status.isError : status.isError && /not configured/.test(status.text),
|
hasIndex ? !status.isError : status.isError && /not configured/.test(status.text),
|
||||||
status.text.split('\n')[0],
|
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) {
|
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 });
|
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]);
|
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 });
|
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],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n== api_get guard rails ==');
|
console.log('\n== api_get guard rails ==');
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import type { Config } from '../config.ts';
|
||||||
import type { SchulcloudClient } from './client.ts';
|
import type { SchulcloudClient } from './client.ts';
|
||||||
|
import { fetchPadText } from './etherpad.ts';
|
||||||
import { SchulcloudApiError } from './client.ts';
|
import { SchulcloudApiError } from './client.ts';
|
||||||
import type { BoardSkeleton, CardResponse, ContentElement, FileRecord } from './types.ts';
|
import type { BoardSkeleton, CardResponse, ContentElement, FileRecord } from './types.ts';
|
||||||
|
|
||||||
@@ -22,6 +24,8 @@ export interface AssembledElement {
|
|||||||
files: FileRecord[];
|
files: FileRecord[];
|
||||||
/** Set when this element's files could not be resolved. */
|
/** Set when this element's files could not be resolved. */
|
||||||
fileError?: string;
|
fileError?: string;
|
||||||
|
/** What a class actually wrote in a collaborativeTextEditor (Etherpad) pad. */
|
||||||
|
padText?: string;
|
||||||
raw: Record<string, unknown>;
|
raw: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +56,7 @@ export async function assembleBoard(
|
|||||||
client: SchulcloudClient,
|
client: SchulcloudClient,
|
||||||
boardId: string,
|
boardId: string,
|
||||||
schoolId: string,
|
schoolId: string,
|
||||||
options: { resolveFiles?: boolean } = {},
|
options: { resolveFiles?: boolean; resolvePads?: Config } = {},
|
||||||
): Promise<AssembledBoard> {
|
): Promise<AssembledBoard> {
|
||||||
const resolveFiles = options.resolveFiles ?? true;
|
const resolveFiles = options.resolveFiles ?? true;
|
||||||
|
|
||||||
@@ -71,6 +75,13 @@ export async function assembleBoard(
|
|||||||
await attachFiles(client, assembled, schoolId);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
const fileCount = assembled
|
const fileCount = assembled
|
||||||
.flatMap((column) => column.cards)
|
.flatMap((column) => column.cards)
|
||||||
.flatMap((card) => card.elements)
|
.flatMap((card) => card.elements)
|
||||||
@@ -121,6 +132,29 @@ function buildElement(element: ContentElement): AssembledElement {
|
|||||||
return assembled;
|
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;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves file-bearing elements to file records.
|
* Resolves file-bearing elements to file records.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
NewsResponse,
|
NewsResponse,
|
||||||
Paginated,
|
Paginated,
|
||||||
SubmissionStatus,
|
SubmissionStatus,
|
||||||
|
LessonLinkedTask,
|
||||||
TaskContent,
|
TaskContent,
|
||||||
} from './types.ts';
|
} from './types.ts';
|
||||||
|
|
||||||
@@ -304,8 +305,22 @@ export class SchulcloudClient {
|
|||||||
return this.getJson<LessonResponse>(`/api/v3/lessons/${encodeURIComponent(lessonId)}`);
|
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 ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- column boards ---------------------------------------------------
|
// --- column boards ---------------------------------------------------
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Config } from '../config.ts';
|
||||||
import { assembleBoard, type AssembledBoard } from './board.ts';
|
import { assembleBoard, type AssembledBoard } from './board.ts';
|
||||||
import type { SchulcloudClient } from './client.ts';
|
import type { SchulcloudClient } from './client.ts';
|
||||||
import { htmlToText, normalizeObjectId } from './text.ts';
|
import { htmlToText, normalizeObjectId } from './text.ts';
|
||||||
@@ -90,6 +91,11 @@ export interface CrawlOptions {
|
|||||||
includeLessonContents?: boolean;
|
includeLessonContents?: boolean;
|
||||||
/** Resolve board file elements to file records. */
|
/** Resolve board file elements to file records. */
|
||||||
includeFiles?: boolean;
|
includeFiles?: 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;
|
||||||
courseConcurrency?: number;
|
courseConcurrency?: number;
|
||||||
boardConcurrency?: number;
|
boardConcurrency?: number;
|
||||||
onProgress?: (done: number, total: number, label: string) => void;
|
onProgress?: (done: number, total: number, label: string) => void;
|
||||||
@@ -203,7 +209,10 @@ async function crawlCourse(
|
|||||||
await forEachLimited(boardIds, options.boardConcurrency ?? 4, async (boardId) => {
|
await forEachLimited(boardIds, options.boardConcurrency ?? 4, async (boardId) => {
|
||||||
let assembled: AssembledBoard;
|
let assembled: AssembledBoard;
|
||||||
try {
|
try {
|
||||||
assembled = await assembleBoard(client, boardId, options.schoolId, { resolveFiles: includeFiles });
|
assembled = await assembleBoard(client, boardId, options.schoolId, {
|
||||||
|
resolveFiles: includeFiles,
|
||||||
|
resolvePads: options.config,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Record rather than swallow: a dropped board used to disappear from the
|
// 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
|
// index while the crawl still reported success, which is how a 20-card
|
||||||
@@ -222,6 +231,9 @@ async function crawlCourse(
|
|||||||
parts.push(card.title);
|
parts.push(card.title);
|
||||||
for (const element of card.elements) {
|
for (const element of card.elements) {
|
||||||
if (element.text) parts.push(htmlToText(element.text));
|
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);
|
||||||
if (element.url) parts.push(element.url);
|
if (element.url) parts.push(element.url);
|
||||||
for (const record of element.files) {
|
for (const record of element.files) {
|
||||||
files.push({
|
files.push({
|
||||||
|
|||||||
100
src/core/etherpad.ts
Normal file
100
src/core/etherpad.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -73,9 +73,17 @@ export function parseHomeworkPage(html: string): SubmissionDetail | undefined {
|
|||||||
|
|
||||||
// The student's own text: a textarea while the submission is still editable,
|
// The student's own text: a textarea while the submission is still editable,
|
||||||
// a plain div once it is not.
|
// 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 =
|
const typed =
|
||||||
/data-testid="submission-text"[^>]*>([\s\S]*?)<\/textarea>/.exec(html)?.[1] ??
|
/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);
|
const typedText = clean(typed);
|
||||||
if (typedText) detail.submittedText = typedText;
|
if (typedText) detail.submittedText = typedText;
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -71,6 +71,39 @@ export interface TaskContent {
|
|||||||
status: TaskStatus;
|
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 {
|
export interface LessonMetaContent {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export class Indexer {
|
|||||||
courseIds: scope === 'full' ? undefined : [scope],
|
courseIds: scope === 'full' ? undefined : [scope],
|
||||||
includeLessonContents: true,
|
includeLessonContents: true,
|
||||||
includeFiles: true,
|
includeFiles: true,
|
||||||
|
config: this.config,
|
||||||
});
|
});
|
||||||
|
|
||||||
const crawlId = await this.store.saveSnapshot(snapshot, scope);
|
const crawlId = await this.store.saveSnapshot(snapshot, scope);
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { ServerContext } from '../../context.ts';
|
import type { ServerContext } from '../../context.ts';
|
||||||
|
import { SchulcloudApiError } from '../../core/client.ts';
|
||||||
import { formatBytes } from '../../core/extract.ts';
|
import { formatBytes } from '../../core/extract.ts';
|
||||||
import { dueLabel, formatDate, heading, htmlToText, joinSections, normalizeObjectId } from '../../core/text.ts';
|
import { dueLabel, formatDate, heading, htmlToText, joinSections, normalizeObjectId } from '../../core/text.ts';
|
||||||
import { assembleBoard, type AssembledBoard, type AssembledElement } from '../../core/board.ts';
|
import { assembleBoard, type AssembledBoard, type AssembledElement } from '../../core/board.ts';
|
||||||
import { forEachLimited } from '../../core/crawl.ts';
|
import { forEachLimited } from '../../core/crawl.ts';
|
||||||
import type { CourseBoardResponse, FileRecord, LessonResponse, TaskContent } from '../../core/types.ts';
|
import { fetchLessonTaskLinks, withScrapedIds } from '../../core/lesson-page.ts';
|
||||||
|
import type {
|
||||||
|
CourseBoardResponse,
|
||||||
|
FileRecord,
|
||||||
|
LessonLinkedTask,
|
||||||
|
LessonResponse,
|
||||||
|
ResolvedTask,
|
||||||
|
} from '../../core/types.ts';
|
||||||
import { failure, text, toToolError } from './result.ts';
|
import { failure, text, toToolError } from './result.ts';
|
||||||
import { describeSubmission } from './submissions.ts';
|
import { describeSubmission } from './submissions.ts';
|
||||||
|
|
||||||
@@ -55,9 +63,24 @@ export function registerContentTools(server: McpServer, context: ServerContext):
|
|||||||
async ({ boardId, includeFiles }) => {
|
async ({ boardId, includeFiles }) => {
|
||||||
try {
|
try {
|
||||||
const schoolId = await context.schoolId();
|
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,
|
||||||
|
});
|
||||||
return text(formatBoard(board, includeFiles));
|
return text(formatBoard(board, includeFiles));
|
||||||
} catch (error) {
|
} 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}`);
|
return toToolError(error, `read board ${boardId}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -80,12 +103,18 @@ export function registerContentTools(server: McpServer, context: ServerContext):
|
|||||||
const schoolId = await context.schoolId();
|
const schoolId = await context.schoolId();
|
||||||
const [lesson, tasks, files] = await Promise.all([
|
const [lesson, tasks, files] = await Promise.all([
|
||||||
context.client.getLesson(lessonId),
|
context.client.getLesson(lessonId),
|
||||||
context.client.getLessonTasks(lessonId).catch(() => undefined),
|
context.client.getLessonTasks(lessonId).catch(() => []),
|
||||||
context.client
|
context.client
|
||||||
.listFiles({ storageLocationId: schoolId, parentType: 'lessons', parentId: lessonId })
|
.listFiles({ storageLocationId: schoolId, parentType: 'lessons', parentId: lessonId })
|
||||||
.catch(() => undefined),
|
.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;
|
||||||
|
return text(formatLesson(lesson, withIds, files?.data ?? []));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return toToolError(error, `read lesson ${lessonId}`);
|
return toToolError(error, `read lesson ${lessonId}`);
|
||||||
}
|
}
|
||||||
@@ -142,7 +171,7 @@ export function registerContentTools(server: McpServer, context: ServerContext):
|
|||||||
* to learn which course the task belongs to (unless told), then read the
|
* to learn which course the task belongs to (unless told), then read the
|
||||||
* description off that course's page.
|
* 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) {
|
if (courseId) {
|
||||||
const fromCourse = await taskFromCourse(context, courseId, taskId);
|
const fromCourse = await taskFromCourse(context, courseId, taskId);
|
||||||
if (fromCourse) return fromCourse;
|
if (fromCourse) return fromCourse;
|
||||||
@@ -168,7 +197,7 @@ async function findTask(context: ServerContext, taskId: string, courseId?: strin
|
|||||||
// back to scanning course pages costs ~26 requests and a few seconds, which
|
// 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.
|
// is a fair price for the tool working instead of claiming the id is wrong.
|
||||||
const courses = await context.client.listAllCourses().catch(() => []);
|
const courses = await context.client.listAllCourses().catch(() => []);
|
||||||
let found: TaskContent | undefined;
|
let found: ResolvedTask | undefined;
|
||||||
await forEachLimited(courses, 6, async (course) => {
|
await forEachLimited(courses, 6, async (course) => {
|
||||||
if (found) return;
|
if (found) return;
|
||||||
const fromCourse = await taskFromCourse(context, course.id, taskId);
|
const fromCourse = await taskFromCourse(context, course.id, taskId);
|
||||||
@@ -181,7 +210,7 @@ async function taskFromCourse(
|
|||||||
context: ServerContext,
|
context: ServerContext,
|
||||||
courseId: string,
|
courseId: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
): Promise<TaskContent | undefined> {
|
): Promise<ResolvedTask | undefined> {
|
||||||
const board = await context.client.getCourseBoard(courseId).catch(() => undefined);
|
const board = await context.client.getCourseBoard(courseId).catch(() => undefined);
|
||||||
if (!board) return undefined;
|
if (!board) return undefined;
|
||||||
for (const element of board.elements) {
|
for (const element of board.elements) {
|
||||||
@@ -189,6 +218,21 @@ async function taskFromCourse(
|
|||||||
return { ...element.content, courseId, courseName: element.content.courseName ?? board.title };
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +296,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 {
|
function formatElement(element: AssembledElement, includeFiles: boolean): string {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case 'richText': {
|
case 'richText': {
|
||||||
@@ -271,8 +323,16 @@ function formatElement(element: AssembledElement, includeFiles: boolean): string
|
|||||||
if (element.files.length === 0) return `- ${element.type} element \`${element.id}\` — no files${caption}`;
|
if (element.files.length === 0) return `- ${element.type} element \`${element.id}\` — no files${caption}`;
|
||||||
return element.files.map((file) => `- ${formatFileLine(file)}${caption}`).join('\n');
|
return element.files.map((file) => `- ${formatFileLine(file)}${caption}`).join('\n');
|
||||||
}
|
}
|
||||||
case 'collaborativeTextEditor':
|
case 'collaborativeTextEditor': {
|
||||||
return `- Collaborative text document \`${element.id}\`${element.text ? ` — ${element.text}` : ''} (contents not available through the API)`;
|
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':
|
case 'externalTool':
|
||||||
return `- External tool${element.text ? `: ${element.text}` : ''} \`${element.id}\``;
|
return `- External tool${element.text ? `: ${element.text}` : ''} \`${element.id}\``;
|
||||||
case 'videoConference':
|
case 'videoConference':
|
||||||
@@ -292,7 +352,7 @@ export function formatFileLine(file: FileRecord): string {
|
|||||||
return `File: **${file.name}** (\`${file.id}\`, ${file.mimeType}, ${formatBytes(file.size)})${blocked}${pending}`;
|
return `File: **${file.name}** (\`${file.id}\`, ${file.mimeType}, ${formatBytes(file.size)})${blocked}${pending}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLesson(lesson: LessonResponse, tasks: TaskContent[], files: FileRecord[]): string {
|
function formatLesson(lesson: LessonResponse, tasks: LessonLinkedTask[], files: FileRecord[]): string {
|
||||||
const sections = (lesson.contents ?? []).map((entry) => {
|
const sections = (lesson.contents ?? []).map((entry) => {
|
||||||
const title = entry.title?.trim();
|
const title = entry.title?.trim();
|
||||||
const component = entry.component ?? 'unknown';
|
const component = entry.component ?? 'unknown';
|
||||||
@@ -316,7 +376,12 @@ function formatLesson(lesson: LessonResponse, tasks: TaskContent[], files: FileR
|
|||||||
tasks.length > 0 &&
|
tasks.length > 0 &&
|
||||||
joinSections([
|
joinSections([
|
||||||
heading(3, `Tasks in this lesson (${tasks.length})`),
|
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'),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -336,7 +401,7 @@ function formatLessonComponent(component: string, content: Record<string, unknow
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTask(task: TaskContent, files: FileRecord[], submission?: string): string {
|
function formatTask(task: ResolvedTask, files: FileRecord[], submission?: string): string {
|
||||||
const description = htmlToText(task.description);
|
const description = htmlToText(task.description);
|
||||||
return joinSections([
|
return joinSections([
|
||||||
heading(2, task.name),
|
heading(2, task.name),
|
||||||
@@ -346,7 +411,11 @@ function formatTask(task: TaskContent, files: FileRecord[], submission?: string)
|
|||||||
task.lessonName ? `- Topic: ${task.lessonName}` : undefined,
|
task.lessonName ? `- Topic: ${task.lessonName}` : undefined,
|
||||||
`- Available from: ${formatDate(task.availableDate)}`,
|
`- Available from: ${formatDate(task.availableDate)}`,
|
||||||
`- Due: ${dueLabel(task.dueDate)}`,
|
`- 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)
|
.filter(Boolean)
|
||||||
.join('\n'),
|
.join('\n'),
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ async function liveSearch(
|
|||||||
courseIds: courseId ? [courseId] : undefined,
|
courseIds: courseId ? [courseId] : undefined,
|
||||||
includeLessonContents: true,
|
includeLessonContents: true,
|
||||||
includeFiles: scoped,
|
includeFiles: scoped,
|
||||||
|
// Same trade as files: worth two extra requests per pad when the caller
|
||||||
|
// named a course, too slow to do across every course they can see.
|
||||||
|
config: scoped ? context.config : undefined,
|
||||||
});
|
});
|
||||||
const hits = searchSnapshot(snapshot, query, limit);
|
const hits = searchSnapshot(snapshot, query, limit);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { ServerContext } from '../../context.ts';
|
import type { ServerContext } from '../../context.ts';
|
||||||
|
import { fetchLessonTaskLinks, withScrapedIds } from '../../core/lesson-page.ts';
|
||||||
import { forEachLimited } from '../../core/crawl.ts';
|
import { forEachLimited } from '../../core/crawl.ts';
|
||||||
import { fetchSubmissionDetail } from '../../core/homework-page.ts';
|
import { fetchSubmissionDetail } from '../../core/homework-page.ts';
|
||||||
import { dueLabel, heading, joinSections } from '../../core/text.ts';
|
import { dueLabel, heading, joinSections } from '../../core/text.ts';
|
||||||
import type { FileRecord, SubmissionStatus, TaskContent } from '../../core/types.ts';
|
import type { FileRecord, ResolvedTask, SubmissionStatus } from '../../core/types.ts';
|
||||||
import { formatFileLine } from './content.ts';
|
import { formatFileLine } from './content.ts';
|
||||||
import { text, toToolError } from './result.ts';
|
import { text, toToolError } from './result.ts';
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ export function registerSubmissionTools(server: McpServer, context: ServerContex
|
|||||||
const [me, tasks] = await Promise.all([context.me(), collectTasks(context, scope, courseId, limit)]);
|
const [me, tasks] = await Promise.all([context.me(), collectTasks(context, scope, courseId, limit)]);
|
||||||
if (tasks.length === 0) return text('No tasks found to check for submissions.');
|
if (tasks.length === 0) return text('No tasks found to check for submissions.');
|
||||||
|
|
||||||
const rows: { task: TaskContent; status: SubmissionStatus }[] = [];
|
const rows: { task: ResolvedTask; status: SubmissionStatus }[] = [];
|
||||||
const unavailable: string[] = [];
|
const unavailable: string[] = [];
|
||||||
|
|
||||||
await forEachLimited(tasks, 5, async (task) => {
|
await forEachLimited(tasks, 5, async (task) => {
|
||||||
@@ -125,7 +126,7 @@ export function formatGradeState(
|
|||||||
return 'marked graded, but neither a percentage nor feedback was found';
|
return 'marked graded, but neither a percentage nor feedback was found';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRow({ task, status }: { task: TaskContent; status: SubmissionStatus }): string {
|
function formatRow({ task, status }: { task: ResolvedTask; status: SubmissionStatus }): string {
|
||||||
const state = status.isSubmitted ? 'submitted' : 'not submitted';
|
const state = status.isSubmitted ? 'submitted' : 'not submitted';
|
||||||
// The list does not fetch pages, so it cannot know whether feedback exists;
|
// The list does not fetch pages, so it cannot know whether feedback exists;
|
||||||
// it says only what the API told it.
|
// it says only what the API told it.
|
||||||
@@ -144,8 +145,8 @@ async function collectTasks(
|
|||||||
scope: 'open' | 'finished' | 'all',
|
scope: 'open' | 'finished' | 'all',
|
||||||
courseId: string | undefined,
|
courseId: string | undefined,
|
||||||
limit: number,
|
limit: number,
|
||||||
): Promise<TaskContent[]> {
|
): Promise<(ResolvedTask & { id: string })[]> {
|
||||||
const wanted: TaskContent[] = [];
|
const wanted: ResolvedTask[] = [];
|
||||||
if (scope === 'open' || scope === 'all') {
|
if (scope === 'open' || scope === 'all') {
|
||||||
wanted.push(...(await context.client.listTasks({ limit }).catch(() => ({ data: [] }))).data);
|
wanted.push(...(await context.client.listTasks({ limit }).catch(() => ({ data: [] }))).data);
|
||||||
}
|
}
|
||||||
@@ -160,10 +161,24 @@ async function collectTasks(
|
|||||||
for (const element of board?.elements ?? []) {
|
for (const element of board?.elements ?? []) {
|
||||||
if (element.type === 'task') wanted.push({ ...element.content, courseId });
|
if (element.type === 'task') wanted.push({ ...element.content, courseId });
|
||||||
}
|
}
|
||||||
|
// Tasks attached to a topic are not task elements on the course page, so
|
||||||
|
// they have to be asked for per topic. They are the ones most likely to
|
||||||
|
// carry a grade: a task old enough to have been marked is usually old
|
||||||
|
// enough to have dropped out of both task lists.
|
||||||
|
for (const element of board?.elements ?? []) {
|
||||||
|
if (element.type !== 'lesson' || !element.content.numberOfPublishedTasks) continue;
|
||||||
|
const [tasks, links] = await Promise.all([
|
||||||
|
context.client.getLessonTasks(element.content.id).catch(() => []),
|
||||||
|
fetchLessonTaskLinks(context.config, courseId, element.content.id),
|
||||||
|
]);
|
||||||
|
// Only the ones whose id could be recovered: a submission lookup needs it.
|
||||||
|
for (const task of withScrapedIds(tasks, links)) if (task.id) wanted.push({ ...task, courseId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return wanted
|
return wanted
|
||||||
|
.filter((task): task is ResolvedTask & { id: string } => Boolean(task.id))
|
||||||
.filter((task) => (courseId ? task.courseId === courseId : true))
|
.filter((task) => (courseId ? task.courseId === courseId : true))
|
||||||
.filter((task) => (seen.has(task.id) ? false : (seen.add(task.id), true)))
|
.filter((task) => (seen.has(task.id) ? false : (seen.add(task.id), true)))
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
|
|||||||
@@ -544,9 +544,12 @@ function fileNode(file: CrawledFile): StoredNode {
|
|||||||
securityCheckStatus: file.record.securityCheckStatus,
|
securityCheckStatus: file.record.securityCheckStatus,
|
||||||
at: file.at,
|
at: file.at,
|
||||||
},
|
},
|
||||||
// File records are immutable, so identity alone decides change; the size
|
// File records are less immutable than they look: `PATCH /file/rename/{id}`
|
||||||
// is included only to catch an upstream record being rewritten in place.
|
// changes the name in place, keeping the id and the size, and teachers do
|
||||||
digest: digestOf([file.record.id, file.record.size]),
|
// rename files. Leaving the name out made that invisible to what_changed —
|
||||||
|
// the file simply reappeared under its new name with nothing reported.
|
||||||
|
// Size still catches a record rewritten in place under the same name.
|
||||||
|
digest: digestOf([file.record.id, file.record.name, file.record.size]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
39
test/etherpad.test.ts
Normal file
39
test/etherpad.test.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import { padIdFromUrl } from '../src/core/etherpad.ts';
|
||||||
|
|
||||||
|
const BASE = 'https://schulcloud.example.org';
|
||||||
|
|
||||||
|
describe('padIdFromUrl', () => {
|
||||||
|
it('takes the pad id out of the url the server hands back', () => {
|
||||||
|
assert.equal(padIdFromUrl(`${BASE}/etherpad/p/g.abc123$65f0e1d2c3b4a5968778695a`, BASE), 'g.abc123$65f0e1d2c3b4a5968778695a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the id exactly as encoded', () => {
|
||||||
|
// Group pad ids contain `$`. Decoding or re-encoding the segment produces
|
||||||
|
// an id Etherpad does not recognise.
|
||||||
|
assert.equal(padIdFromUrl(`${BASE}/etherpad/p/g.x%24y`, BASE), 'g.x%24y');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a url pointing at another host', () => {
|
||||||
|
// The caller sends an Etherpad session cookie to whatever this returns, and
|
||||||
|
// the url comes from server configuration — so a mismatch must not be
|
||||||
|
// followed rather than trusted.
|
||||||
|
assert.equal(padIdFromUrl('https://evil.test/etherpad/p/g.abc$123', BASE), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a differing port only when it matches', () => {
|
||||||
|
assert.equal(padIdFromUrl('http://localhost:4400/etherpad/p/pad1', 'http://localhost:4400'), 'pad1');
|
||||||
|
assert.equal(padIdFromUrl('http://localhost:9001/etherpad/p/pad1', 'http://localhost:4400'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives up on a url that is not a pad url', () => {
|
||||||
|
assert.equal(padIdFromUrl(`${BASE}/dashboard`, BASE), undefined);
|
||||||
|
assert.equal(padIdFromUrl(`${BASE}/etherpad/p/`, BASE), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives up rather than throwing on something that is not a url', () => {
|
||||||
|
assert.equal(padIdFromUrl('not a url', BASE), undefined);
|
||||||
|
assert.equal(padIdFromUrl('', BASE), undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,11 +7,12 @@ import { parseHomeworkPage } from '../src/core/homework-page.ts';
|
|||||||
* as actually served — including the parts that are absent for a student, which
|
* as actually served — including the parts that are absent for a student, which
|
||||||
* is what makes every field optional.
|
* is what makes every field optional.
|
||||||
*/
|
*/
|
||||||
const page = (parts: { submission?: string; feedback?: string }) => `
|
const page = (parts: { submission?: string; feedback?: string; afterSubmission?: string }) => `
|
||||||
<html><body>
|
<html><body>
|
||||||
<nav class="nav tab-links"><a href="#activetabid=extended">Details</a></nav>
|
<nav class="nav tab-links"><a href="#activetabid=extended">Details</a></nav>
|
||||||
<section id="extended" class="tab-content">keine Beschreibung vorhanden</section>
|
<section id="extended" class="tab-content">keine Beschreibung vorhanden</section>
|
||||||
<section id="submission" class="tab-content">${parts.submission ?? ''}</section>
|
<section id="submission" class="tab-content">${parts.submission ?? ''}</section>
|
||||||
|
${parts.afterSubmission ?? ''}
|
||||||
${parts.feedback === undefined ? '' : `<section id="feedback" class="tab-content">${parts.feedback}</section>`}
|
${parts.feedback === undefined ? '' : `<section id="feedback" class="tab-content">${parts.feedback}</section>`}
|
||||||
</body></html>`;
|
</body></html>`;
|
||||||
|
|
||||||
@@ -39,10 +40,30 @@ describe('parseHomeworkPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('reads the typed answer from the read-only form once submission closed', () => {
|
it('reads the typed answer from the read-only form once submission closed', () => {
|
||||||
const html = page({ submission: `<div class="comment"><p>Abgegebener Text</p></div>` });
|
// The read-only `<div class="comment">` is a sibling *after* the closed
|
||||||
|
// submission section, not a child of it — verified against a real 33.40
|
||||||
|
// page for a past-due submission. A fixture that nested it inside the
|
||||||
|
// section (as an earlier one did) hid a bug where every past-due
|
||||||
|
// submission lost its text. See src/core/homework-page.ts.
|
||||||
|
const html = page({
|
||||||
|
submission: `<section class="files" data-testid="submissions-section-files"></section>`,
|
||||||
|
afterSubmission: `<div class="comment"><p>Abgegebener Text</p></div>`,
|
||||||
|
});
|
||||||
assert.equal(parseHomeworkPage(html)?.submittedText, 'Abgegebener Text');
|
assert.equal(parseHomeworkPage(html)?.submittedText, 'Abgegebener Text');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not confuse the submitted text with the teacher feedback comment', () => {
|
||||||
|
// Both are comment divs, distinguished only by the exact class: the
|
||||||
|
// student's is `class="comment"`, the teacher's `class="comment ckcontent"`.
|
||||||
|
const html = page({
|
||||||
|
afterSubmission: `<div class="comment"><p>Meine Abgabe</p></div>`,
|
||||||
|
feedback: `<div class="comment ckcontent" data-testid="feedback-comment"><p>Gut gemacht</p></div>`,
|
||||||
|
});
|
||||||
|
const detail = parseHomeworkPage(html);
|
||||||
|
assert.equal(detail?.submittedText, 'Meine Abgabe');
|
||||||
|
assert.equal(detail?.gradeComment, 'Gut gemacht');
|
||||||
|
});
|
||||||
|
|
||||||
it('separates submitted files from files the teacher returned', () => {
|
it('separates submitted files from files the teacher returned', () => {
|
||||||
const html = page({
|
const html = page({
|
||||||
submission: fileCard('a'.repeat(24), 'meine-abgabe.pdf'),
|
submission: fileCard('a'.repeat(24), 'meine-abgabe.pdf'),
|
||||||
|
|||||||
82
test/lesson-page.test.ts
Normal file
82
test/lesson-page.test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import { parseLessonTaskLinks, withScrapedIds } from '../src/core/lesson-page.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixtures mirror the legacy topic page as actually served — the task cards it
|
||||||
|
* renders under `<div id="homeworks">`, which is the only place a topic-attached
|
||||||
|
* task's id is exposed at all.
|
||||||
|
*/
|
||||||
|
const taskCard = (id: string, name: string) =>
|
||||||
|
`<li class="card assignment"><a href="/homework/${id}" aria-label="Details der Aufgabe: '${name}'">` +
|
||||||
|
`<div class="dates">Fällig: 30.07.2026 15:23</div></a></li>`;
|
||||||
|
|
||||||
|
const page = (cards: string) =>
|
||||||
|
`<html><body><h2>Aufgaben <small>(2)</small></h2>` +
|
||||||
|
`<div id="homeworks" class="container-fluid collapse"><ol class="homework">${cards}</ol></div>` +
|
||||||
|
`</body></html>`;
|
||||||
|
|
||||||
|
const id = (c: string) => c.repeat(24);
|
||||||
|
|
||||||
|
describe('parseLessonTaskLinks', () => {
|
||||||
|
it('recovers the ids the API withholds, with their names', () => {
|
||||||
|
const html = page(taskCard(id('a'), 'Bestandteile einer Kamera') + taskCard(id('b'), 'Eigenschaften einer Sammellinse'));
|
||||||
|
assert.deepEqual(parseLessonTaskLinks(html), [
|
||||||
|
{ id: id('a'), name: 'Bestandteile einer Kamera' },
|
||||||
|
{ id: id('b'), name: 'Eigenschaften einer Sammellinse' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes entities in task names', () => {
|
||||||
|
const html = page(taskCard(id('c'), 'Größe & Form'));
|
||||||
|
assert.equal(parseLessonTaskLinks(html)[0]?.name, 'Größe & Form');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports each task once even when the page links it twice', () => {
|
||||||
|
// The card title and its action button both point at the same task.
|
||||||
|
const html = page(taskCard(id('d'), 'Würfelspiel') + taskCard(id('d'), 'Würfelspiel'));
|
||||||
|
assert.equal(parseLessonTaskLinks(html).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing rather than guessing when the markup is not a topic page', () => {
|
||||||
|
assert.deepEqual(parseLessonTaskLinks('<html><body>Anmelden</body></html>'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores homework links that carry no task name', () => {
|
||||||
|
// A bare link with no aria-label cannot be paired with an API task, so it
|
||||||
|
// is no use; taking it would produce a task named after nothing.
|
||||||
|
assert.deepEqual(parseLessonTaskLinks(page(`<a href="/homework/${id('e')}">Aufgabe</a>`)), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('withScrapedIds', () => {
|
||||||
|
it('gives the API tasks the ids from the page, matching on name', () => {
|
||||||
|
const tasks = [{ name: 'Würfelspiel' }, { name: 'Sammellinse' }];
|
||||||
|
const links = [
|
||||||
|
{ id: id('a'), name: 'Sammellinse' },
|
||||||
|
{ id: id('b'), name: 'Würfelspiel' },
|
||||||
|
];
|
||||||
|
assert.deepEqual(
|
||||||
|
withScrapedIds(tasks, links).map((t) => t.id),
|
||||||
|
[id('b'), id('a')],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a task the page does not account for without an id', () => {
|
||||||
|
// Still worth reporting — the user can see it — but the id-taking tools
|
||||||
|
// cannot open it, so it must not be handed a fabricated id.
|
||||||
|
const merged = withScrapedIds([{ name: 'Nur in der API' }], [{ id: id('a'), name: 'Etwas anderes' }]);
|
||||||
|
assert.equal(merged[0]?.id, undefined);
|
||||||
|
assert.equal(merged[0]?.name, 'Nur in der API');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite an id the task already has', () => {
|
||||||
|
const merged = withScrapedIds([{ id: id('f'), name: 'Würfelspiel' }], [{ id: id('a'), name: 'Würfelspiel' }]);
|
||||||
|
assert.equal(merged[0]?.id, id('f'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates surrounding whitespace in the API name', () => {
|
||||||
|
const merged = withScrapedIds([{ name: ' Würfelspiel ' }], [{ id: id('a'), name: 'Würfelspiel' }]);
|
||||||
|
assert.equal(merged[0]?.id, id('a'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -106,6 +106,22 @@ describe('Store', { skip: DB_URL ? false : 'set TEST_DATABASE_URL to run' }, ()
|
|||||||
assert.ok(diff.changed.some((n) => n.nodeId === 'c1-b'), 'board body change detected via digest');
|
assert.ok(diff.changed.some((n) => n.nodeId === 'c1-b'), 'board body change detected via digest');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports a renamed file, which keeps its id and size', async () => {
|
||||||
|
// `PATCH /file/rename/{id}` renames a record in place. The digest once
|
||||||
|
// covered only id and size on the assumption that file records never
|
||||||
|
// change, so a rename went unreported — the file just quietly appeared
|
||||||
|
// under a new name.
|
||||||
|
const before = await store.latestCrawlId();
|
||||||
|
const after = await store.saveSnapshot(
|
||||||
|
snapshot([{ id: 'c1', title: 'Mathe', boardText: 'Prozentrechnung', files: [{ id: 'f2', name: 'b-v2.pdf', size: 20 }] }]),
|
||||||
|
'full',
|
||||||
|
);
|
||||||
|
const diff = await store.diff(before!, after);
|
||||||
|
assert.ok(diff.changed.some((n) => n.nodeId === 'f2'), 'rename detected');
|
||||||
|
assert.ok(!diff.added.some((n) => n.nodeId === 'f2'), 'a rename is not a new file');
|
||||||
|
assert.ok(!diff.removed.some((n) => n.nodeId === 'f2'), 'and not a deleted one');
|
||||||
|
});
|
||||||
|
|
||||||
it('carries other courses forward on a per-course crawl', async () => {
|
it('carries other courses forward on a per-course crawl', async () => {
|
||||||
await store.saveSnapshot(
|
await store.saveSnapshot(
|
||||||
snapshot([
|
snapshot([
|
||||||
|
|||||||
Reference in New Issue
Block a user