Schulcloud says what was uploaded and WebUntis says what was scheduled. Neither says what was *taught* — which point the teacher laboured, which example landed, what "will definitely come up". That lives in two places this server could not reach: the notes the user takes in the lesson, and WebUntis' class register. Notes are a directory of Markdown files (NOTES_DIR), not a table. They have to be writable from a phone in a classroom, readable when Postgres is down, and outlive this project, and files are the only shape that is all three — so the files are the truth and the index is a view of them, the same split as file_texts and the mirror. list_notes and get_note read disk, so they answer before the first crawl; search, what_changed and all three German prompts read them alongside the Schulcloud material. add_note writes one, and is the only thing in this server that writes anything. That is not a hole in the read-only invariant but a different store: it is bounded to NOTES_DIR by the same safeComponent/resolveWithin pair that stops a hostile Schulcloud filename escaping the mirror, so a note titled ../../.ssh/authorized_keys becomes a filename. Schulcloud and WebUntis stay GET-only and allowlisted respectively. NOTES_READONLY refuses writes outright. Appending targets the *lesson*, not the title: "halt das auch noch fest" mid-lesson carries a new title, and deriving the path from it would start a second note every time, which is the one thing append exists to prevent. Notes.app has no export — its bodies are compressed protobuf and the iCloud copy is encrypted — so scripting the app is not the clumsy route to the notes but the only one. scripts/export-apple-notes.js reads them through AppleScript into one JSON object per line, and `schulcloud note import` converts the HTML to Markdown, takes the Notes folder as the subject and the *creation* date as the lesson's date. Attachments cannot come across; a note that was a photo of the board imports as a line saying so, because importing it empty would hide the loss. The class register needed one API property to become cheap: getLessonTopic2017 answers per *series*, not per period, so a term is reconstructed by asking about the latest period of each lesson series and merging back by id — a few dozen calls for a school year rather than one per lesson. untis_lesson_topics now takes a subject as well as a period id, and UNTIS_HISTORY_DAYS of register goes into the index under a kind of its own, so "what did we actually do before the test" is searchable. Sharing the snapshot rather than duplicating it caught one thing on the way: the search tool's live path had to learn notes too, or fresh=true would have quietly disagreed with the index. 305 tests; 88/89 smoke against the local instance, the one failure being the H5P service that instance does not run. The live smoke could not be retaken: that session has lapsed and needs a fresh jwt cookie. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
91 lines
2.7 KiB
YAML
91 lines
2.7 KiB
YAML
# The server and its Postgres.
|
|
#
|
|
# Locally, docker-compose.override.yml is merged in automatically (docs/LOCAL.md).
|
|
# On the Pi, .env selects deploy/docker-compose.pi.yml instead, which attaches
|
|
# the server to the network of the Caddy already running there (docs/PI.md).
|
|
|
|
services:
|
|
# The index's own Postgres, on a private network with the server. An existing
|
|
# instance works too — point DATABASE_URL at it and drop this service — but
|
|
# nothing requires sharing one.
|
|
postgres:
|
|
image: postgres:17-alpine
|
|
container_name: schulcloud-mcp-db
|
|
restart: unless-stopped
|
|
environment:
|
|
POSTGRES_DB: schulcloud
|
|
POSTGRES_USER: schulcloud
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-schulcloud}
|
|
volumes:
|
|
- pgdata:/var/lib/postgresql/data
|
|
# Only the server talks to Postgres. Keeping it off the Caddy network keeps
|
|
# it out of reach of whatever else shares that network on the Pi.
|
|
networks:
|
|
- backend
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U schulcloud -d schulcloud"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
|
|
schulcloud-mcp:
|
|
build: .
|
|
image: schulcloud-mcp:latest
|
|
container_name: schulcloud-mcp
|
|
restart: unless-stopped
|
|
env_file: .env
|
|
environment:
|
|
PORT: 8080
|
|
BIND_HOST: 0.0.0.0
|
|
MIRROR_DIR: /data/mirror
|
|
STATE_DIR: /data/state
|
|
NOTES_DIR: /data/notes
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
volumes:
|
|
# The mirror, a replaced Schulcloud token and the user's own notes are the
|
|
# only things this server writes; everything else stays read-only, so each
|
|
# gets its own volume rather than loosening read_only.
|
|
- mirror:/data/mirror
|
|
- state:/data/state
|
|
# The notes. Swap this line for a bind mount to keep them in a folder the
|
|
# user already syncs (Syncthing, Nextcloud, an Obsidian vault) and write
|
|
# them from a phone instead of through the API — see docs/NOTES.md:
|
|
# - /home/pi/Notizen:/data/notes
|
|
- notes:/data/notes
|
|
# No ports are published to the host: Caddy reaches the container over the
|
|
# shared Docker network, so the only way in from the internet is through
|
|
# Caddy's TLS and this server's bearer check.
|
|
expose:
|
|
- "8080"
|
|
networks:
|
|
- caddy
|
|
- backend
|
|
logging:
|
|
driver: json-file
|
|
options:
|
|
max-size: "10m"
|
|
max-file: "3"
|
|
security_opt:
|
|
- no-new-privileges:true
|
|
read_only: true
|
|
tmpfs:
|
|
- /tmp
|
|
cap_drop:
|
|
- ALL
|
|
|
|
volumes:
|
|
pgdata:
|
|
mirror:
|
|
state:
|
|
notes:
|
|
|
|
networks:
|
|
backend:
|
|
caddy:
|
|
# On the Pi, deploy/docker-compose.pi.yml makes this the network your
|
|
# existing Caddy already uses (see docs/PI.md).
|
|
external: false
|
|
name: caddy
|