Files
Schulcloud-MCP/local-instance/docker-compose.yml
MechaCat02 bed3923902 Browse the file manager ("Dateien") as a filesystem
Many teachers never use topics or boards; their material sits in the
course's file area, and the tools answered "0 files" for courses holding
dozens of worksheets — 21 of 26 courses on the live account. Persönliche,
Kurs-, Team- and Geteilte Dateien live in the legacy file store, not in
files-storage, and its service is not in the public ingress. The only way in
is the legacy client: HTML listings, and GET /files/signedurl for a
pre-signed download.

core/legacy-files.ts turns that into one path tree — /my, /courses/<course>,
/teams/<team>, /shared — resolving names that contain "/", ids anywhere in a
path, and wrong or ambiguous names with a message saying what is there. A
listing that does not parse throws; it never reads as an empty folder.

Some of the legacy client's GET routes write (GET /files/share/ mints a
share token), so getFileManagerPage allows only the listing routes, by
pattern. Signed URLs are fetched with no credentials and must be https.

- MCP: fs_list, fs_tree, fs_find and fs_read; get_course lists course files.
- CLI: schulcloud fs ls, tree, find and get, recursive and resumable.
- API: /api/fs/list, tree, find and file.
- Index: the crawl walks the file manager (INDEX_FILE_MANAGER, on by
  default), so search covers the text inside those files and sync mirrors
  them under <course>/Kurs-Dateien.

The local instance gains a fixture for all four areas. It needed a loopback,
so signed URLs open from the host, and a pre-created bucket, since MinIO
does not implement PutBucketCors.

135 tests. Smoke 55/55 live; 57/57 and 55/55 on the local instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:16 +02:00

319 lines
12 KiB
YAML

# A local Schulcloud, as close to schulcloud-thueringen.de as it can be made
# without its external identity provider.
#
# The application images are the *same* images the real instance runs
# (quay.io/schulcloudverbund, thr theme, tag 33.40 — see README.md), so the
# behaviour under test is the deployed behaviour, not a rebuild of main.
#
# docker compose up -d core: login, courses, boards, files
# docker compose --profile tools up -d + h5p, tldraw, collabora
# docker compose --profile av up -d + virus scanning of uploads
#
# Everything here is a throwaway dev instance: the credentials are the upstream
# development defaults and are published in the upstream repositories. Do not
# expose any of it beyond localhost.
x-sc-version: &sc-version "${SC_VERSION:-33.40}"
x-server-image: &server-image
image: quay.io/schulcloudverbund/schulcloud-server:${SC_VERSION:-33.40}
env_file: [env/shared.env, env/jwt.env, env/api.env]
depends_on:
mongo: {condition: service_healthy}
valkey: {condition: service_started}
rabbitmq: {condition: service_healthy}
restart: unless-stopped
services:
# ---------------------------------------------------------------- infra ---
mongo:
image: docker.io/mongo:7
# Single-node replica set rather than a bare mongod: the server's migration
# runner opens transactions, which mongo refuses outside a replica set.
command: ["--replSet", "rs0", "--bind_ip_all"]
volumes:
- mongo-data:/data/db
ports: ["127.0.0.1:27019:27017"]
healthcheck:
# Initiates the replica set on first start and reports healthy once the
# node is actually primary, which is what every other service waits for.
test: >-
mongosh --quiet --eval '
try { rs.status() } catch (e) { rs.initiate({_id:"rs0",members:[{_id:0,host:"mongo:27017"}]}) }
quit(db.hello().isWritablePrimary ? 0 : 1)'
interval: 5s
timeout: 10s
retries: 30
start_period: 10s
restart: unless-stopped
valkey:
# The JWT whitelist. Sessions die when their key expires, exactly as in
# production — this is the piece that makes local session testing honest.
image: docker.io/valkey/valkey:8-alpine
ports: ["127.0.0.1:6381:6379"]
restart: unless-stopped
rabbitmq:
image: docker.io/rabbitmq:4-management-alpine
ports: ["127.0.0.1:15673:15672"]
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 10s
retries: 20
start_period: 20s
restart: unless-stopped
minio:
# Stands in for the S3 provider the real instance uses. Buckets are created
# by minio-init below.
image: quay.io/minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: miniouser
MINIO_ROOT_PASSWORD: miniouser
volumes:
- minio-data:/data
ports:
- "127.0.0.1:9900:9000" # S3 API
- "127.0.0.1:9901:9001" # console (miniouser / miniouser)
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 30
restart: unless-stopped
minio-init:
image: quay.io/minio/mc:latest
depends_on:
minio: {condition: service_healthy}
entrypoint: ["/bin/sh", "/init.sh"]
volumes:
- ./scripts/minio-init.sh:/init.sh:ro
restart: "no"
# ------------------------------------------------------- schulcloud api ---
api:
<<: *server-image
container_name: sc-api
command: ["dist/apps/server/apps/server.app"]
ports: ["127.0.0.1:3030:3030"]
management:
# Not part of the running instance — it exposes the seeding and migration
# endpoints that the real deployment's init job calls, and nothing else.
<<: *server-image
command: ["dist/apps/server/apps/management.app"]
# Port and base path are hardcoded to 3333 and /api in management.app.ts;
# PORT is not read here.
ports: ["127.0.0.1:3333:3333"]
board-collaboration:
# The websocket behind column boards. Without it a board renders once and
# then never updates.
<<: *server-image
command: ["dist/apps/server/apps/board-collaboration.app"]
environment:
PORT: "4450"
admin-api:
<<: *server-image
command: ["dist/apps/server/apps/admin-api-server.app"]
environment:
PORT: "4030"
file-storage:
image: quay.io/schulcloudverbund/file-storage:${SC_VERSION:-33.40}
env_file: [env/shared.env, env/jwt.env, env/file-storage.env]
depends_on:
mongo: {condition: service_healthy}
rabbitmq: {condition: service_healthy}
minio: {condition: service_healthy}
ports: ["127.0.0.1:4444:4444"]
restart: unless-stopped
file-storage-consumer:
# The AMQP half of files-storage, and a separate entrypoint from the HTTP
# one: only `files-storage-consumer.app` registers FilesStorageConsumer, so
# running the HTTP app alone leaves the `files-storage` exchange with no
# queue bound to it.
#
# The symptom is not a file problem. TaskService.delete awaits
# deleteFilesOfParent over AMQP before touching the task, so with nothing
# consuming, deleting a task or a topic hangs until the request timeout and
# answers 408 REQUEST_TIMEOUT with the entity still there. Copying a course
# goes the same way.
image: quay.io/schulcloudverbund/file-storage:${SC_VERSION:-33.40}
command: ["dist/apps/files-storage-consumer.app.js"]
env_file: [env/shared.env, env/jwt.env, env/file-storage.env]
depends_on:
mongo: {condition: service_healthy}
rabbitmq: {condition: service_healthy}
minio: {condition: service_healthy}
restart: unless-stopped
minio-loopback:
# The legacy file service (the file manager: Persönliche/Kurs-/Team-Dateien)
# signs upload and download URLs for its storage provider's one endpoint, and
# the same endpoint serves its own S3 calls. "minio:9000" works inside the
# compose network and nowhere else, so a browser or the MCP server on this
# machine was handed URLs it could not open: uploads through the UI failed
# and downloads could not be tested. seed.sh registers the provider as
# http://localhost:9900 instead, and this forwards that address to MinIO from
# inside the api container's own network namespace — so the one URL now
# works from the api, from the browser and from the host alike.
image: alpine/socat:1.8.0.1
network_mode: "service:api"
command: ["TCP-LISTEN:9900,fork,reuseaddr,bind=127.0.0.1", "TCP:minio:9000"]
depends_on:
api: {condition: service_started}
minio: {condition: service_healthy}
restart: unless-stopped
file-preview:
# Generates thumbnails via ImageMagick, driven off RabbitMQ. Optional: with
# it absent, files still upload and download, they just have no preview.
image: quay.io/schulcloudverbund/file-storage:file-preview-${SC_VERSION:-33.40}
profiles: ["preview"]
env_file: [env/shared.env, env/jwt.env, env/file-storage.env]
volumes:
# The image's own ImageMagick policy denies every coder it needs; see the
# comment in the file. Without this the profile runs but produces nothing.
- ./file-preview/policy.xml:/etc/ImageMagick-7/policy.xml:ro
depends_on:
rabbitmq: {condition: service_healthy}
minio: {condition: service_healthy}
restart: unless-stopped
# -------------------------------------------------------------- clients ---
client:
# The legacy UI. Still owns "/" and much of the course view.
image: quay.io/schulcloudverbund/schulcloud-client-thr:${SC_VERSION:-33.40}
env_file: [env/shared.env, env/jwt.env, env/client.env]
depends_on: [api]
ports: ["127.0.0.1:3100:3100"]
restart: unless-stopped
nuxt:
# The Vue SPA, built for the thr theme. The image is an nginx that
# templates env vars into its config at start.
image: quay.io/schulcloudverbund/schulcloud-frontend-thr:${SC_VERSION:-33.40}
env_file: [env/nuxt.env]
ports: ["127.0.0.1:4000:4000"]
restart: unless-stopped
etherpad:
# The collaborative text editor element.
#
# Core, not a "tool", however much it looks like one: the legacy client asks
# the server for an Etherpad session on *every* topic page whose lesson has
# contents, without checking whether the lesson contains a pad at all
# (controllers/topics.js builds `etherpadPads` and then never reads it).
# Unreachable, that call fails, `validUntil` arrives undefined, and
# `new Date(undefined * 1000)` makes Express reject the session cookie —
# "option expires is invalid", a 500 on every topic page. The live
# deployment always runs Etherpad (ETHERPAD_REPLICAS: 1), so keeping it in
# the default profile is both the working and the faithful choice.
image: docker.io/etherpad/etherpad:3.3.3
env_file: [env/etherpad.env]
volumes:
- ./etherpad/APIKEY.txt:/opt/etherpad-lite/APIKEY.txt:ro
depends_on:
mongo: {condition: service_healthy}
restart: unless-stopped
proxy:
# The single origin. Everything a browser touches goes through here, so the
# app sees one host the way it does in production.
image: docker.io/nginx:1.29-alpine
volumes:
- ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
ports: ["127.0.0.1:4400:4400"]
depends_on: [api, client, nuxt]
restart: unless-stopped
# -------------------------------------------------- external tools -------
h5p-editor:
image: quay.io/schulcloudverbund/h5p-server:${SC_VERSION:-33.40}
profiles: ["tools"]
command: ["dist/apps/h5p-editor.app"]
env_file: [env/shared.env, env/jwt.env, env/h5p.env]
environment:
PORT: "4448"
depends_on:
mongo: {condition: service_healthy}
minio: {condition: service_healthy}
restart: unless-stopped
h5p-staticfiles:
image: quay.io/schulcloudverbund/h5p-server:static-files-${SC_VERSION:-33.40}
profiles: ["tools"]
restart: unless-stopped
h5p-libraries:
# One-shot: installs the H5P content types listed in env/h5p.env into the
# library bucket. Exits when done; re-run it after changing that list.
image: quay.io/schulcloudverbund/h5p-server:${SC_VERSION:-33.40}
profiles: ["tools"]
command: ["dist/apps/h5p-library-management.app"]
env_file: [env/shared.env, env/jwt.env, env/h5p.env]
depends_on:
mongo: {condition: service_healthy}
minio-init: {condition: service_completed_successfully}
restart: "no"
tldraw-server:
# The whiteboard element.
image: quay.io/schulcloudverbund/tldraw-server:${SC_VERSION:-33.40}
profiles: ["tools"]
command: ["dist/apps/tldraw-server.app.js"]
env_file: [env/shared.env, env/jwt.env, env/tldraw.env]
depends_on: [valkey, minio]
restart: unless-stopped
tldraw-worker:
image: quay.io/schulcloudverbund/tldraw-server:${SC_VERSION:-33.40}
profiles: ["tools"]
command: ["dist/apps/tldraw-worker.app.js"]
env_file: [env/shared.env, env/jwt.env, env/tldraw.env]
depends_on: [valkey, minio]
restart: unless-stopped
collabora:
# Office document editing. Reached by the browser directly on :9980, the
# way the real deployment puts it on its own hostname.
image: docker.io/collabora/code:latest
profiles: ["tools"]
environment:
extra_params: --o:ssl.enable=false --o:ssl.termination=false
domain: ".*"
aliasgroup1: "http://localhost:4400"
ports: ["127.0.0.1:9980:9980"]
cap_add: ["MKNOD"]
restart: unless-stopped
# ------------------------------------------------------------ antivirus ---
clamav:
# ~1.5 GB resident once the signature database loads, hence its own profile.
image: docker.io/clamav/clamav:1.5.3
profiles: ["av"]
volumes:
- clamav-db:/var/lib/clamav
restart: unless-stopped
clammit:
image: ghcr.io/dbildungsplattform/clammit:0.9.1
profiles: ["av"]
environment:
CLAMMIT_CLAMD_URL: tcp://clamav:3310
CLAMMIT_LISTEN: 0.0.0.0:8438
depends_on: [clamav]
restart: unless-stopped
volumes:
mongo-data:
minio-data:
clamav-db: