Skip to content

Commit 8a2ccc4

Browse files
author
Nils Bars
committed
Front Flask and the Vue SPA with a Caddy frontend-proxy on port 8000
Adds a new frontend-proxy service (multi-stage Dockerfile: node builder stage compiles the SPA bundle; caddy:2-alpine runtime stage serves it) that terminates the host HTTP port and fans out over the existing web-host network: /spa/* -> spa-frontend:5173 (vite dev) in dev, baked /srv/spa-dist via file_server in prod /static/* -> webapp/ref/static bind-mount served directly by Caddy /admin, /admin/ -> 302 to /admin/exercise/view /spa -> 308 /spa/ everything else -> reverse_proxy web:8000 (X-Tinyproxy set from remote_host so the rate limiter keys on real client IP) Dev/prod Caddyfiles are selected at container start via entrypoint.sh based on $HOT_RELOADING. Prod adds Cache-Control headers: immutable + long max-age on /spa/assets/*, no-cache on the SPA HTML fallback, 1h on Flask static assets. Caddy's reverse_proxy auto-upgrades Vite's HMR websocket for /spa/@vite/client. The SPA URL prefix moves from /v2 to /spa (vite base, router history, Flask landing redirects). The /api/v2/* JSON API path is unchanged. The spa-frontend service is gated behind the 'dev' compose profile so it only starts when hot-reloading is active; ctrl.sh exports COMPOSE_PROFILES=dev by default and clears it in prod-mode up. The prod-mode branch of spa-frontend/entrypoint.sh is now a hard error since the prod bundle is baked into the frontend-proxy image. web and spa-frontend no longer publish host ports; the test harness maps frontend-proxy:8000 instead. SPA_HOST_PORT is removed from settings.yaml/env and prepare.py. A repo-root .dockerignore keeps the frontend-proxy build context lean. UI: - SPA DefaultLayout app bar gets an unobtrusive mdi-shield-account-outline icon button linking to /admin. - Admin navbar gains a "Student View" link (school icon + label) with a pipe separator from the user name/logout group. - Admin navbar brand (logo + course name) now links to the exercise view instead of the SPA landing.
1 parent c9617ee commit 8a2ccc4

19 files changed

Lines changed: 311 additions & 64 deletions

File tree

.claude/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ REF is a containerized platform for hosting programming exercises with isolated
162162
- `ref/model/` - SQLAlchemy models (users, groups, exercises, instances, submissions, grades, system settings)
163163
- `ref/core/` - Business logic managers (`ExerciseManager`, `InstanceManager`, `ExerciseImageManager`, `UserManager`, `DockerClient`, etc.)
164164

165-
Student-facing pages (registration, restore-key, public scoreboard) are served by the Vue SPA under `/v2/*` and talk to `ref/frontend_api/`. Admin pages live under `ref/view/` as Jinja-rendered HTML.
165+
Student-facing pages (registration, restore-key, public scoreboard) are served by the Vue SPA under `/spa/*` and talk to `ref/frontend_api/`. Admin pages live under `ref/view/` as Jinja-rendered HTML. The Caddy `frontend-proxy` container fronts both on a single host port 8000 — it reverse-proxies `/spa/*` to `spa-frontend:5173` (dev, with HMR) or serves a baked SPA bundle (prod), serves Flask's `/static/*` directly, and proxies everything else to `web:8000`.
166166

167167
2. **SSH Reverse Proxy** (`ssh-reverse-proxy/`) - Rust-based SSH proxy on port 2222
168168
- Routes student SSH connections to exercise containers

.dockerignore

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
**/.git
2+
**/node_modules
3+
**/__pycache__
4+
**/*.pyc
5+
**/.venv
6+
**/.mypy_cache
7+
**/.ruff_cache
8+
**/.pytest_cache
9+
10+
ref-linux/
11+
data/
12+
backup_exercises/
13+
ref-exercises/
14+
.docker-cache/
15+
16+
tests/
17+
tests/failure_logs/
18+
19+
webapp/ref_webapp.egg-info/
20+
21+
.codex/
22+
.claude/
23+
24+
docs/
25+
*.md
26+
27+
spa-frontend/dist/

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,4 +184,4 @@ The following features are disabled by default and can be enabled from the admin
184184
Allows students to be organized into named groups with a configurable maximum size. Students pick a group during registration, and admins can manage the available groups and reassign students afterwards. Enable via the `GROUPS_ENABLED` setting and configure the per-group capacity via `GROUP_SIZE`.
185185

186186
#### Scoreboard
187-
A public leaderboard at `/v2/scoreboard` that ranks students based on their exercise submissions using a Formula 1 style, time-weighted strategy. Exercises can be grouped into assignments. Enable via `SCOREBOARD_ENABLED`; optionally set `LANDING_PAGE` to `scoreboard` to use it as the default landing page.
187+
A public leaderboard at `/spa/scoreboard` that ranks students based on their exercise submissions using a Formula 1 style, time-weighted strategy. Exercises can be grouped into assignments. Enable via `SCOREBOARD_ENABLED`; optionally set `LANDING_PAGE` to `scoreboard` to use it as the default landing page.

ctrl.sh

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,13 @@ if [[ -z "$HTTP_HOST_PORT" ]]; then
276276
exit 1
277277
fi
278278

279+
# The spa-frontend service is gated behind the `dev` compose profile so it
280+
# is only started when --hot-reloading is active. Activate the profile for
281+
# every ctrl.sh subcommand so profile-gated services can still be
282+
# built/stopped/inspected; the `up` function unsets this again for prod
283+
# mode so spa-frontend does not get started there.
284+
export COMPOSE_PROFILES=dev
285+
279286
if [[ -z "$SECRET_KEY" ]]; then
280287
error "Please set SECRET_KEY in $ENV_SETTINGS_FILE to a random string"
281288
exit 1
@@ -422,6 +429,12 @@ function up {
422429
esac
423430
done
424431

432+
if [[ "$HOT_RELOADING" != "true" ]]; then
433+
# Prod mode: skip the profile-gated spa-frontend service. Caddy
434+
# serves the baked SPA bundle from the frontend-proxy image.
435+
unset COMPOSE_PROFILES
436+
fi
437+
425438
execute_cmd $DOCKER_COMPOSE -p ref --env-file $ENV_SETTINGS_FILE up "$@"
426439
}
427440

docker-compose.template.yml

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,6 @@ services:
101101
- coverage-data:/coverage-data:rw
102102
- ./coverage:/coverage-config:ro
103103
{% endif %}
104-
{% if not testing %}
105-
ports:
106-
- "${HTTP_HOST_PORT}:8000"
107-
{% endif %}
108104
networks:
109105
- web-host
110106
- web-and-ssh
@@ -113,16 +109,53 @@ services:
113109
- db
114110
cgroup_parent: "{{ cgroup_parent }}-core.slice"
115111

116-
# Vue 3 + Vuetify SPA that serves the student-facing pages under /v2/
117-
# (registration, restore-key, scoreboard). In dev (HOT_RELOADING=true)
118-
# runs `vite dev` with HMR against the host bind-mounted source;
119-
# otherwise runs `vite build && vite preview`. Either way the
120-
# container's port 5173 is mapped to SPA_HOST_PORT on the host, and
121-
# Vite's proxy forwards /api, /student/download, and /static to the
122-
# web container over the web-host network.
112+
# Caddy reverse proxy that fronts the whole web interface on a single
113+
# host port. Routes /spa/* to the SPA (vite dev in dev mode, baked
114+
# static bundle in prod) and /static/* directly from webapp/ref/static;
115+
# everything else is reverse-proxied to the Flask web container. The
116+
# SPA build artifact is baked into this image at docker build time via
117+
# a multi-stage Dockerfile, so prod does not need `vite preview` or a
118+
# shared volume. Dev selection is done at container start by
119+
# entrypoint.sh based on $HOT_RELOADING.
120+
frontend-proxy:
121+
init: true
122+
hostname: frontend-proxy
123+
build:
124+
context: .
125+
dockerfile: frontend-proxy/Dockerfile
126+
environment:
127+
- HOT_RELOADING=${HOT_RELOADING:-false}
128+
volumes:
129+
# Serve Flask's static assets directly from Caddy, skipping
130+
# uWSGI. Read-only to keep the proxy sandboxed.
131+
- ./webapp/ref/static:/srv/flask-static:ro
132+
{% if not testing %}
133+
ports:
134+
- "${HTTP_HOST_PORT}:8000"
135+
{% endif %}
136+
networks:
137+
- web-host
138+
depends_on:
139+
- web
140+
cgroup_parent: "{{ cgroup_parent }}-core.slice"
141+
healthcheck:
142+
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/static/favicon.ico"]
143+
interval: 10s
144+
timeout: 3s
145+
retries: 5
146+
start_period: 5s
147+
148+
# Vue 3 + Vuetify SPA dev server. Only started in the `dev` compose
149+
# profile (ctrl.sh adds --profile dev when --hot-reloading is set).
150+
# In dev it runs `vite dev` with HMR against the host bind-mounted
151+
# source; frontend-proxy reverse-proxies /spa/* to this container's
152+
# port 5173 (including Vite's HMR websocket). In prod this service
153+
# is not started at all — frontend-proxy serves the baked SPA bundle.
123154
spa-frontend:
124155
init: true
125156
hostname: spa-frontend
157+
profiles:
158+
- dev
126159
build:
127160
context: ./spa-frontend
128161
environment:
@@ -133,10 +166,6 @@ services:
133166
# so deps installed at build time remain available.
134167
- ./spa-frontend/:/spa-frontend
135168
- /spa-frontend/node_modules
136-
{% if not testing %}
137-
ports:
138-
- "${SPA_HOST_PORT:-5173}:5173"
139-
{% endif %}
140169
networks:
141170
- web-host
142171
depends_on:

docs/ARCHITECTURE.md

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,32 @@ Remote Exercise Framework - A platform for hosting programming exercises with is
55
## System Overview
66

77
```
8-
┌─────────────────────────────────────────────────────────────────┐
9-
│ HOST SYSTEM │
10-
├─────────────────────────────────────────────────────────────────┤
11-
│ Port 2222 ──> ssh-reverse-proxy (Rust) ──> Instance (SSH) │
12-
│ Port 8000 ──> web (Flask) ──> Docker API ──> Instance Mgmt │
13-
└─────────────────────────────────────────────────────────────────┘
8+
┌──────────────────────────────────────────────────────────────────────┐
9+
│ HOST SYSTEM │
10+
├──────────────────────────────────────────────────────────────────────┤
11+
│ Port 2222 ──> ssh-reverse-proxy (Rust) ──> Instance (SSH) │
12+
│ Port 8000 ──> frontend-proxy (Caddy) ──┬─> web (Flask) │
13+
│ ├─> spa-frontend (vite dev) │
14+
│ └─> baked SPA dist/ (prod) │
15+
└──────────────────────────────────────────────────────────────────────┘
1416
```
1517

18+
The `frontend-proxy` Caddy container terminates host port 8000 and routes
19+
traffic by URL prefix:
20+
21+
- `/spa/*` — the Vue SPA. In dev (`--hot-reloading`) proxied to the
22+
`spa-frontend` container running `vite dev` with HMR; in prod served as
23+
a static bundle baked into the frontend-proxy image at build time via a
24+
multi-stage Dockerfile.
25+
- `/static/*` — Flask's own static assets (bootstrap, ace-builds, favicon,
26+
etc.), served directly by Caddy from a read-only bind-mount of
27+
`webapp/ref/static/`.
28+
- Everything else (`/`, `/admin/*`, `/api/*`, `/student/*`) — reverse-proxied
29+
to the Flask `web` container on the internal `web-host` network.
30+
31+
The `ssh-reverse-proxy` still calls `http://web:8000` over the internal
32+
`web-and-ssh` network and does **not** go through Caddy.
33+
1634
## Components
1735

1836
### 1. Web Application (`webapp/`)
@@ -96,6 +114,35 @@ Isolated Docker container per student/exercise based on Ubuntu 24.04.
96114

97115
**Entry point:** SSH server on port 13370
98116

117+
### 2b. Frontend Proxy (`frontend-proxy/`)
118+
119+
Caddy-based reverse proxy container that terminates host port 8000 and fans
120+
out to the Flask webapp and the Vue SPA. Built from a multi-stage
121+
Dockerfile that compiles the SPA bundle (stage 1: `node:22-alpine`,
122+
`npm run build`) and copies it into a `caddy:2-alpine` runtime image
123+
(stage 2). At container start, `entrypoint.sh` picks `Caddyfile.dev`
124+
(reverse-proxies `/spa/*` to `spa-frontend:5173` for HMR) or
125+
`Caddyfile.prod` (serves the baked `/srv/spa-dist` with SPA history-mode
126+
fallback) based on `$HOT_RELOADING`.
127+
128+
**Stack:** Caddy 2 + multi-stage Node builder
129+
130+
**Key files:**
131+
- `Dockerfile` — multi-stage SPA build + Caddy runtime
132+
- `Caddyfile.dev` — dev routing (proxies `/spa/*` to vite dev)
133+
- `Caddyfile.prod` — prod routing (serves baked dist with cache headers)
134+
- `entrypoint.sh` — selects config based on `HOT_RELOADING`
135+
136+
**Notes:**
137+
- The Flask rate limiter reads `X-Tinyproxy` to key on the real client IP;
138+
Caddy sets this header via `header_up X-Tinyproxy {remote_host}` on the
139+
reverse-proxy path.
140+
- Flask static assets (`/static/*`) are served directly by Caddy with a 1h
141+
cache header, skipping uWSGI.
142+
- SPA hashed assets (`/spa/assets/*`) are served with
143+
`public, max-age=31536000, immutable`; `index.html` is `no-cache` so
144+
deploys are picked up atomically.
145+
99146
### 3. SSH Reverse Proxy (`ssh-reverse-proxy/`)
100147

101148
Rust-based SSH proxy routing student connections to their containers.
@@ -160,7 +207,7 @@ PostgreSQL 17.2 storing:
160207

161208
| Network | Bridge Name | Type | Purpose |
162209
|---------|-------------|------|---------|
163-
| `web-host` | `br-whost-ref` | External | Web ↔ Host (HTTP access) |
210+
| `web-host` | `br-whost-ref` | External | frontend-proxy ↔ Host, frontend-proxy ↔ web, frontend-proxy ↔ spa-frontend |
164211
| `web-and-ssh` | `br-w2ssh-ref` | Internal | Web ↔ SSH reverse proxy API |
165212
| `web-and-db` | `br-w2db-ref` | Internal | Web ↔ PostgreSQL |
166213
| `ssh-and-host` | `br-shost-ref` | External | SSH reverse proxy ↔ Host |

docs/SCOREBOARD.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Scoreboard
22

3-
A public leaderboard at `/v2/scoreboard` that ranks students/teams based
3+
A public leaderboard at `/spa/scoreboard` that ranks students/teams based
44
on submission scores. Exercises are grouped into **assignments**
55
(time-boxed rounds, one per `ExerciseConfig.category`). Each exercise
66
has **per-task scoring policies** that transform the raw score of each

frontend-proxy/Caddyfile.dev

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
admin off
3+
auto_https off
4+
}
5+
6+
:8000 {
7+
log {
8+
output stdout
9+
format console
10+
}
11+
12+
redir /spa /spa/ 308
13+
14+
# /admin and /admin/ have no Flask view of their own; route both to
15+
# the exercise list (the first item in the admin navbar dropdown).
16+
redir /admin /admin/exercise/view 302
17+
redir /admin/ /admin/exercise/view 302
18+
19+
handle /spa/* {
20+
reverse_proxy spa-frontend:5173
21+
}
22+
23+
handle_path /static/* {
24+
root * /srv/flask-static
25+
file_server
26+
}
27+
28+
handle {
29+
reverse_proxy web:8000 {
30+
header_up X-Tinyproxy {remote_host}
31+
}
32+
}
33+
}

frontend-proxy/Caddyfile.prod

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
admin off
3+
auto_https off
4+
}
5+
6+
:8000 {
7+
log {
8+
output stdout
9+
format console
10+
}
11+
12+
redir /spa /spa/ 308
13+
14+
# /admin and /admin/ have no Flask view of their own; route both to
15+
# the exercise list (the first item in the admin navbar dropdown).
16+
redir /admin /admin/exercise/view 302
17+
redir /admin/ /admin/exercise/view 302
18+
19+
handle_path /spa/* {
20+
root * /srv/spa-dist
21+
22+
@immutable path /assets/*
23+
header @immutable Cache-Control "public, max-age=31536000, immutable"
24+
25+
# Everything outside /assets/* is either index.html itself or a
26+
# deep-link that try_files falls back to index.html. The header
27+
# matcher evaluates against the current request path (before the
28+
# try_files rewrite), so "not /assets/*" catches all of them.
29+
@html not path /assets/*
30+
header @html Cache-Control "no-cache"
31+
32+
try_files {path} {path}/ /index.html
33+
file_server
34+
}
35+
36+
handle_path /static/* {
37+
root * /srv/flask-static
38+
header Cache-Control "public, max-age=3600"
39+
file_server
40+
}
41+
42+
handle {
43+
reverse_proxy web:8000 {
44+
header_up X-Tinyproxy {remote_host}
45+
}
46+
}
47+
}

frontend-proxy/Dockerfile

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
FROM node:22-alpine AS spa-builder
2+
WORKDIR /build
3+
COPY spa-frontend/package.json spa-frontend/package-lock.json ./
4+
RUN npm ci
5+
COPY spa-frontend/ ./
6+
RUN npm run build
7+
8+
FROM caddy:2.8-alpine
9+
COPY --from=spa-builder /build/dist /srv/spa-dist
10+
COPY frontend-proxy/Caddyfile.dev /etc/caddy/Caddyfile.dev
11+
COPY frontend-proxy/Caddyfile.prod /etc/caddy/Caddyfile.prod
12+
COPY frontend-proxy/entrypoint.sh /entrypoint.sh
13+
RUN chmod +x /entrypoint.sh
14+
ENTRYPOINT ["/entrypoint.sh"]

0 commit comments

Comments
 (0)