Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5b263b0
feat(frontend): Migrate /settings and /login to SPA
joschahenningsen Aug 16, 2026
1976229
chore: Remove legacy /settings and /login
joschahenningsen Aug 16, 2026
eef95c0
fix(apiv2): don't panic on a stream without a lecture hall
joschahenningsen Aug 16, 2026
a559a07
fix(apiv2): stop listing restricted live courses to anonymous callers
joschahenningsen Aug 16, 2026
3082b73
refactor(apiv2): pass the request context to the DAO
joschahenningsen Aug 16, 2026
388a559
refactor(model): express authority as permissions rather than roles
joschahenningsen Aug 16, 2026
d273893
fix(model): don't let an unpersisted user administer an unowned course
joschahenningsen Aug 16, 2026
a8f39a4
refactor: gate routes and handlers on permissions instead of roles
joschahenningsen Aug 16, 2026
57b68f1
feat(apiv2): add a unary interceptor chain for logging and authentica…
joschahenningsen Aug 16, 2026
9af3357
feat(apiv2): enforce a declared access policy on every RPC, default deny
joschahenningsen Aug 16, 2026
26ca07d
refactor(apiv2): split the single API service into four domain services
joschahenningsen Aug 16, 2026
0be3c91
refactor(apiv2): extract course and stream visibility rules
joschahenningsen Aug 16, 2026
f9f5036
refactor(apiv2): map gorm lookup failures in one place
joschahenningsen Aug 16, 2026
af76c0d
fix(apiv2): answer 401 for a rejected credential on anonymous endpoints
joschahenningsen Aug 16, 2026
24738b9
fix(apiv2): don't list a live stream whose course could not be loaded
joschahenningsen Aug 16, 2026
9f4615f
fix(web): require a permission for the admin pages
joschahenningsen Aug 16, 2026
cc9897d
feat(apiv2): add getFrontendConfig
joschahenningsen Aug 16, 2026
7575128
fix(frontend): port the missing popup and notification body styles
joschahenningsen Aug 16, 2026
2559a93
fix(frontend): match the playback speed defaults to the server's
joschahenningsen Aug 16, 2026
1104e54
fix(apiv2): restore the preferred name rules lost in the v1 port
joschahenningsen Aug 16, 2026
a6d8b47
fix(frontend): stop sharing notification storage with the legacy fron…
joschahenningsen Aug 16, 2026
14964b7
fix(frontend): retry the user load after an inconclusive failure
joschahenningsen Aug 16, 2026
6fee490
fix(frontend): don't let a failed settings reload escape as a rejection
joschahenningsen Aug 16, 2026
b1d0766
chore: regenerate frontend protos
joschahenningsen Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions .github/workflows/frontend-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
name: "Frontend"

on:
push:
branches: [ dev ]
pull_request:
branches: [ dev ]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Install modules
working-directory: ./frontend
run: npm ci
- name: Typecheck
working-directory: ./frontend
run: npm run typecheck
- name: Test
working-directory: ./frontend
run: npm test
# Building here also guards the Go side: web/spa_test.go skips when no build is
# present, so without this the router wiring would go unverified in CI.
- name: Build
working-directory: ./frontend
run: npm run build

# Browser tests against a real server: a MariaDB seeded from the starter dump, the
# Go binary serving both frontends, and Playwright driving Chromium against it. This
# is the only job that can see gin choosing between the SPA shell and a template, or
# the session cookie surviving a login redirect.
#
# The server runs on the config.yaml in the repository root, unchanged — the same
# file a developer starts with, which is the point: if it stops being enough to boot
# a working server, this job says so. Two of its settings matter here. The db block
# has to match the service container below, and the ldap block has to stay disabled:
# with it, a rejected password falls back to LDAP and the login test waits for
# ldap://abc.de to answer, which it never will.
e2e:
runs-on: ubuntu-latest
services:
db:
image: mariadb:11
env:
MARIADB_ROOT_PASSWORD: example
MARIADB_DATABASE: tumlive
ports:
- 3306:3306
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--health-interval=5s
--health-timeout=5s
--health-retries=30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Install Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version: 1.26.x

# The starter dump creates the schema and the example courses, users and streams
# the pages render. The server migrates it forward on boot, so an old dump is
# fine. Run through the mariadb image rather than a client on the runner, so the
# client version always matches the server and nothing depends on what happens to
# be preinstalled.
- name: Seed the database
run: |
docker run --rm --network host -v "$PWD:/repo:ro" mariadb:11 \
sh -c 'mariadb -h 127.0.0.1 -P 3306 -u root -pexample < /repo/tum-live-starter.sql'

# web/router.go embeds web/node_modules, so this is needed to compile at all, not
# just to make the icons show up.
- name: Install template assets
working-directory: ./web
run: npm ci

# Without a build, every migrated route falls back to its template and the suite
# would test the frontend it is replacing.
- name: Build the SPA
working-directory: ./frontend
run: npm ci && npm run build

- name: Start the server
run: |
go run cmd/tumlive/main.go > server.log 2>&1 &
echo $! > server.pid
for _ in $(seq 1 60); do
if curl -fsS -o /dev/null http://localhost:8081/login; then
echo "server is up"
exit 0
fi
# Compiling and the first migration take a while; fail early if it died.
if ! kill -0 "$(cat server.pid)" 2>/dev/null; then
echo "server exited before it started serving"
cat server.log
exit 1
fi
sleep 2
done
echo "server did not start within two minutes"
cat server.log
exit 1

# After the server, so that the account is created against the migrated schema
# rather than the 2022 one in the dump.
- name: Create the test account
run: go run ./frontend/e2e/seeduser

- name: Install Playwright browsers
working-directory: ./frontend
run: npx playwright install --with-deps chromium

- name: Run the browser tests
working-directory: ./frontend
run: npm run test:e2e

- name: Collect the server log
if: failure()
run: cat server.log

# Traces and screenshots for the failing tests; Playwright is configured to keep
# them only on failure.
- name: Upload the Playwright report
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: playwright-report
path: |
frontend/playwright-report/
frontend/test-results/
retention-days: 7
5 changes: 5 additions & 0 deletions .github/workflows/go-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ jobs:
- name: Install modules
working-directory: ./web
run: npm ci
# web/spa_test.go skips when no SPA build is present, so build it here or the
# router wiring goes untested.
- name: Build SPA
working-directory: ./frontend
run: npm ci && npm run build
- name: Install Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
Expand Down
9 changes: 8 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@ FROM node:25 AS node

WORKDIR /app
COPY web web
COPY frontend frontend

## remove generated files in case the developer build with npm before
RUN rm -rf web/assets/ts-dist &&\
rm -rf web/assets/css-dist
rm -rf web/assets/css-dist &&\
rm -rf web/spa/assets web/spa/index.html

WORKDIR /app/web
RUN npm i --no-dev

## build the single-page app serving the migrated pages; output lands in web/spa
WORKDIR /app/frontend
RUN npm ci && npm run build

FROM golang:1.26 AS build-env

RUN mkdir /gostuff
Expand All @@ -23,6 +29,7 @@ WORKDIR /go/src/app
COPY . .
COPY --from=node /app/web/assets ./web/assets
COPY --from=node /app/web/node_modules ./web/node_modules
COPY --from=node /app/web/spa ./web/spa

# bundle version into binary if specified in build-args, dev otherwise.
ARG version=dev
Expand Down
35 changes: 34 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.PHONY: all
all: npm_dependencies go_dependencies bundle
all: npm_dependencies spa go_dependencies bundle

VERSION := $(shell git rev-parse --short origin/HEAD)

Expand All @@ -8,6 +8,23 @@ npm_dependencies:
cd web; \
npm i --no-dev

# Builds the single-page frontend into web/spa, where it is embedded into the binary.
# Skipping this target leaves every page served by its template handler.
.PHONY: spa
spa:
cd frontend; \
npm ci; \
npm run build

# Regenerates the TypeScript client in frontend/src/gen from apiv2/server/apiv2.proto.
# The output is committed, so this only needs running when the proto changes; use it
# together with apiv2/generate.sh, which regenerates the Go side from the same file.
.PHONY: proto_es
proto_es:
cd frontend; \
npm ci; \
npm run proto

.PHONY: go_dependencies
go_dependencies:
go get ./...
Expand All @@ -19,6 +36,8 @@ bundle:
.PHONY: clean
clean:
rm -fr web/node_modules
rm -fr frontend/node_modules
rm -fr web/spa/assets web/spa/index.html

.PHONY: install
install:
Expand All @@ -40,11 +59,25 @@ run:
.PHONY: test
test:
go test -race ./...
cd frontend; npm test

# Browser tests against a running server. Not part of `test`: these need the server and
# its database up, and they sign in as an account that has to exist first.
#
# go run ./frontend/e2e/seeduser # once, and again whenever the database is reset
# make run # in another terminal
# make test_e2e
.PHONY: test_e2e
test_e2e:
cd frontend; \
npx playwright install --with-deps chromium; \
npm run test:e2e

.PHONY: lint
lint:
golangci-lint run
cd web; npm run lint
cd frontend; npm run typecheck

.PHONY: protoVoice
protoVoice:
Expand Down
2 changes: 1 addition & 1 deletion api/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ type auditRoutes struct {
func configAuditRouter(r *gin.Engine, d dao.DaoWrapper) {
auditRouter := auditRoutes{d}
g := r.Group("/api")
g.Use(tools.Admin)
g.Use(tools.RequirePermission(model.PermAdministerServer))
{
g.GET("/audits", auditRouter.getAudits)
}
Expand Down
6 changes: 3 additions & 3 deletions api/courses.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func configGinCourseRouter(router *gin.Engine, daoWrapper dao.DaoWrapper) {

lecturers := api.Group("")
{
lecturers.Use(tools.AtLeastLecturer)
lecturers.Use(tools.RequirePermission(model.PermLecture))
lecturers.POST("/courseInfo", routes.courseInfo)
lecturers.POST("/createCourse", routes.createCourse)
lecturers.POST("/createTestCourse", routes.createTestCourse)
Expand Down Expand Up @@ -1519,7 +1519,7 @@ func (r coursesRoutes) createCourse(c *gin.Context) {
Streams: []model.Stream{},
Language: lang,
}
if tumLiveContext.User.Role != model.AdminType {
if !tumLiveContext.User.Can(model.PermAdministerAllCourses) {
course.Admins = []model.User{*tumLiveContext.User}
}

Expand Down Expand Up @@ -1697,7 +1697,7 @@ func (r coursesRoutes) copyStream(c *gin.Context) {
}
tlctx := c.MustGet("TUMLiveContext").(tools.TUMLiveContext)

isAdmin := tlctx.User.Role == model.AdminType
isAdmin := tlctx.User.Can(model.PermAdministerAllCourses)

if !isAdmin {
targetCourseAdmins, err := r.DaoWrapper.CoursesDao.GetCourseAdmins(request.TargetCourse)
Expand Down
2 changes: 1 addition & 1 deletion api/info-pages.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func configInfoPageRouter(router *gin.Engine, wrapper dao.DaoWrapper) {
routes := infoPageRoutes{wrapper}
api := router.Group("/api")
{
api.Use(tools.Admin)
api.Use(tools.RequirePermission(model.PermAdministerServer))
api.PUT("/texts/:id", routes.updateText)
}
}
Expand Down
4 changes: 2 additions & 2 deletions api/lecture_halls.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func configGinLectureHallApiRouter(router *gin.Engine, daoWrapper dao.DaoWrapper
}

admins := router.Group("/api")
admins.Use(tools.Admin)
admins.Use(tools.RequirePermission(model.PermAdministerServer))
admins.PUT("/lectureHall/:id", routes.updateLectureHall)
admins.POST("/lectureHall/:id/defaultPreset", routes.updateLectureHallsDefaultPreset)
admins.DELETE("/lectureHall/:id", routes.deleteLectureHall)
Expand Down Expand Up @@ -255,7 +255,7 @@ func (r lectureHallRoutes) lectureHallIcal(c *gin.Context) {
tumLiveContext := foundContext.(tools.TUMLiveContext)
// pass 0 to db query to get all lectures if user is not logged in or admin
queryUid := uint(0)
if tumLiveContext.User != nil && tumLiveContext.User.Role != model.AdminType {
if tumLiveContext.User != nil && !tumLiveContext.User.Can(model.PermViewAllCourses) {
queryUid = tumLiveContext.User.ID
}
icalData, err := r.LectureHallsDao.GetStreamsForLectureHallIcal(queryUid, lectureHalls, all)
Expand Down
2 changes: 1 addition & 1 deletion api/maintenance.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func configMaintenanceRouter(router *gin.Engine, daoWrapper dao.DaoWrapper) {
routes := maintenanceRoutes{DaoWrapper: daoWrapper}

g := router.Group("/api/maintenance")
g.Use(tools.Admin)
g.Use(tools.RequirePermission(model.PermAdministerServer))
{
g.POST("/generateThumbnails", routes.generateThumbnails)
g.GET("/generateThumbnails/status", routes.getThumbGenProgress)
Expand Down
4 changes: 2 additions & 2 deletions api/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ func configNotificationsRouter(r *gin.Engine, daoWrapper dao.DaoWrapper) {
{
notifications.GET("/", routes.getNotifications)
notifications.GET("/server", routes.getServerNotifications)
notifications.POST("/", tools.Admin, routes.createNotification)
notifications.DELETE("/:id", tools.Admin, routes.deleteNotification)
notifications.POST("/", tools.RequirePermission(model.PermAdministerServer), routes.createNotification)
notifications.DELETE("/:id", tools.RequirePermission(model.PermAdministerServer), routes.deleteNotification)
}
}

Expand Down
4 changes: 2 additions & 2 deletions api/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func liveRunnerPageUpdateOnSubscribe(psc *realtime.Context) {

if tumLiveContext.User != nil {
userId = tumLiveContext.User.ID
if tumLiveContext.User.Role != model.AdminType {
if !tumLiveContext.User.Can(model.PermAdministerServer) {
err = errors.New("user is not admin")
logger.Error("User is not admin", "err", err)
return
Expand Down Expand Up @@ -228,7 +228,7 @@ type runnerRoutes struct {

func configRunnerRouter(r *gin.Engine, daoWrapper dao.DaoWrapper) {
g := r.Group("/api/runners")
g.Use(tools.Admin)
g.Use(tools.RequirePermission(model.PermAdministerServer))

routes := runnerRoutes{dao: daoWrapper.RunnerDao}

Expand Down
4 changes: 2 additions & 2 deletions api/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ func meiliStreamFilter(c *gin.Context, user *model.User, semester model.Semester
}

semesterFilter := fmt.Sprintf("(year = %d AND semester = \"%s\")", semester.Year, semester.TeachingTerm)
if user != nil && user.Role == model.AdminType {
if user.Can(model.PermViewAllCourses) {
return semesterFilter
}

Expand Down Expand Up @@ -397,7 +397,7 @@ func meiliStreamFilter(c *gin.Context, user *model.User, semester model.Semester
// ignores either semesters or firstSemester/lastSemester, depending on semesters == nil
func meiliCourseFilter(c *gin.Context, user *model.User, firstSemester model.Semester, lastSemester model.Semester, semesters []model.Semester) string {
semesterFilter := meiliSemesterFilter(firstSemester, lastSemester, semesters)
if user != nil && user.Role == model.AdminType {
if user.Can(model.PermViewAllCourses) {
return semesterFilter
}

Expand Down
2 changes: 1 addition & 1 deletion api/server-notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (
func configServerNotificationsRoutes(engine *gin.Engine, daoWrapper dao.DaoWrapper) {
routes := serverNotificationRoutes{daoWrapper}
adminGroup := engine.Group("/api/serverNotification")
adminGroup.Use(tools.Admin)
adminGroup.Use(tools.RequirePermission(model.PermAdministerServer))
adminGroup.POST("/:notificationId", routes.updateServerNotification)
adminGroup.POST("/create", routes.createServerNotification)
}
Expand Down
4 changes: 2 additions & 2 deletions api/statistics.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func (r coursesRoutes) getStats(c *gin.Context) {
// check if request is for server -> validate
cidFromContext := c.Param("courseID")
if cidFromContext == "0" {
if ctx.(tools.TUMLiveContext).User.Role != model.AdminType {
if !ctx.(tools.TUMLiveContext).User.Can(model.PermAdministerServer) {
_ = c.Error(tools.RequestError{
Status: http.StatusForbidden,
CustomMessage: "not admin",
Expand Down Expand Up @@ -283,7 +283,7 @@ func (r coursesRoutes) exportStats(c *gin.Context) {
// check if request is for server -> validate
cidFromContext := c.Param("courseId")
if cidFromContext == "0" {
if ctx.(tools.TUMLiveContext).User.Role != model.AdminType {
if !ctx.(tools.TUMLiveContext).User.Can(model.PermAdministerServer) {
_ = c.Error(tools.RequestError{
Status: http.StatusForbidden,
CustomMessage: "not admin",
Expand Down
Loading
Loading