morz-infoboard/server/backend/internal/httpapi/router.go
Jesko Anschütz 0e66bfdb24 Tenant-Feature Phase 6: Session-Cleanup, Docker-Env, Security-Fixes, Doku
Session-Cleanup:
- app.go: stündlicher Ticker für CleanExpiredSessions mit Context-Shutdown

Docker/Infra:
- compose/.env.example: Vorlage für ADMIN_PASSWORD, DEV_MODE, DEFAULT_TENANT
- server-stack.yml: Backend-Service referenziert neue Env-Variablen

Security-Review (Larry):
- EnsureAdminUser: Admin-Check tenant-scoped statt global
- scanUser() (toter Code, falsche Spaltenanzahl) entfernt
- RequireTenantAccess: leerer tenantSlug nicht mehr als Bypass nutzbar
- Login: Dummy-bcrypt bei unbekanntem User gegen Timing-Leak
- Logout-Cookie: Secure-Flag konsistent mit Login gesetzt

Doku (Doris):
- DEVELOPMENT.md: Abschnitt "Lokale Entwicklung mit Login"
- TENANT-FEATURE-PLAN.md: Phase 3-5 Checkboxen abgehakt
- TODO.md: erledigte Punkte abgehakt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:39:39 +01:00

178 lines
9.4 KiB
Go

package httpapi
import (
"log"
"net/http"
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi/manage"
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi/tenant"
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
// RouterDeps holds all dependencies needed to build the HTTP router.
type RouterDeps struct {
StatusStore playerStatusStore
TenantStore *store.TenantStore
ScreenStore *store.ScreenStore
MediaStore *store.MediaStore
PlaylistStore *store.PlaylistStore
AuthStore *store.AuthStore
Notifier *mqttnotifier.Notifier
Config config.Config
UploadDir string
Logger *log.Logger
}
func NewRouter(deps RouterDeps) http.Handler {
mux := http.NewServeMux()
// ── Health ───────────────────────────────────────────────────────────
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"service": "morz-infoboard-backend",
})
})
// ── Status / diagnostic UI ───────────────────────────────────────────
mux.HandleFunc("GET /status", handleStatusPage(deps.StatusStore))
mux.HandleFunc("GET /status/{screenId}", handleScreenDetailPage(deps.StatusStore))
// ── API meta ─────────────────────────────────────────────────────────
mux.HandleFunc("GET /api/v1", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"name": "morz-infoboard-backend",
"version": "dev",
"tools": []string{
"message-wall-resolve",
"screen-status-list",
"screen-status-detail",
"player-status-ingest",
"screen-status-delete",
},
})
})
mux.HandleFunc("GET /api/v1/meta", handleMeta)
// ── Player status (existing) ──────────────────────────────────────────
mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus(deps.StatusStore))
mux.HandleFunc("GET /api/v1/screens/status", handleListLatestPlayerStatuses(deps.StatusStore))
mux.HandleFunc("GET /api/v1/screens/{screenId}/status", handleGetLatestPlayerStatus(deps.StatusStore))
mux.HandleFunc("DELETE /api/v1/screens/{screenId}/status", handleDeletePlayerStatus(deps.StatusStore))
// ── Message wall ──────────────────────────────────────────────────────
mux.HandleFunc("POST /api/v1/tools/message-wall/resolve", handleResolveMessageWall)
// ── Playlist management — only register if stores are wired up ────────
if deps.TenantStore != nil {
registerManageRoutes(mux, deps)
}
return mux
}
func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
uploadDir := d.UploadDir
if uploadDir == "" {
uploadDir = "/tmp/morz-uploads"
}
// Ensure notifier is never nil inside handlers (no-op when broker not configured).
notifier := d.Notifier
if notifier == nil {
notifier = mqttnotifier.New("", "", "")
}
// Serve uploaded files.
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadDir))))
// Serve embedded static assets (Bulma CSS, SortableJS) — no external CDN needed.
mux.HandleFunc("GET /static/bulma.min.css", manage.HandleStaticBulmaCSS())
mux.HandleFunc("GET /static/Sortable.min.js", manage.HandleStaticSortableJS())
// ── Auth (no auth middleware required) ────────────────────────────────
mux.HandleFunc("GET /login", manage.HandleLoginUI(d.AuthStore))
mux.HandleFunc("POST /login", manage.HandleLoginPost(d.AuthStore, d.Config))
mux.HandleFunc("POST /logout", manage.HandleLogoutPost(d.AuthStore, d.Config))
// Shorthand middleware combinators for this router.
authOnly := func(h http.Handler) http.Handler {
return chain(h, RequireAuth(d.AuthStore))
}
authAdmin := func(h http.Handler) http.Handler {
return chain(h, RequireAuth(d.AuthStore), RequireAdmin)
}
authTenant := func(h http.Handler) http.Handler {
return chain(h, RequireAuth(d.AuthStore), RequireTenantAccess)
}
// ── Admin UI ──────────────────────────────────────────────────────────
mux.Handle("GET /admin",
authAdmin(http.HandlerFunc(manage.HandleAdminUI(d.TenantStore, d.ScreenStore))))
mux.Handle("POST /admin/screens/provision",
authAdmin(http.HandlerFunc(manage.HandleProvisionUI(d.TenantStore, d.ScreenStore))))
mux.Handle("POST /admin/screens",
authAdmin(http.HandlerFunc(manage.HandleCreateScreenUI(d.TenantStore, d.ScreenStore))))
mux.Handle("POST /admin/screens/{screenId}/delete",
authAdmin(http.HandlerFunc(manage.HandleDeleteScreenUI(d.ScreenStore))))
// ── Playlist management UI ────────────────────────────────────────────
mux.Handle("GET /manage/{screenSlug}",
authOnly(http.HandlerFunc(manage.HandleManageUI(d.TenantStore, d.ScreenStore, d.MediaStore, d.PlaylistStore))))
mux.Handle("POST /manage/{screenSlug}/upload",
authOnly(http.HandlerFunc(manage.HandleUploadMediaUI(d.MediaStore, d.ScreenStore, uploadDir))))
mux.Handle("POST /manage/{screenSlug}/items",
authOnly(http.HandlerFunc(manage.HandleAddItemUI(d.PlaylistStore, d.MediaStore, d.ScreenStore, notifier))))
mux.Handle("POST /manage/{screenSlug}/items/{itemId}",
authOnly(http.HandlerFunc(manage.HandleUpdateItemUI(d.PlaylistStore, notifier))))
mux.Handle("POST /manage/{screenSlug}/items/{itemId}/delete",
authOnly(http.HandlerFunc(manage.HandleDeleteItemUI(d.PlaylistStore, notifier))))
mux.Handle("POST /manage/{screenSlug}/reorder",
authOnly(http.HandlerFunc(manage.HandleReorderUI(d.PlaylistStore, d.ScreenStore, notifier))))
mux.Handle("POST /manage/{screenSlug}/media/{mediaId}/delete",
authOnly(http.HandlerFunc(manage.HandleDeleteMediaUI(d.MediaStore, d.ScreenStore, uploadDir, notifier))))
// ── JSON API — screens ────────────────────────────────────────────────
// Self-registration: no auth (player calls this on startup).
mux.HandleFunc("POST /api/v1/screens/register",
manage.HandleRegisterScreen(d.TenantStore, d.ScreenStore, d.Config))
mux.Handle("GET /api/v1/tenants/{tenantSlug}/screens",
authTenant(http.HandlerFunc(manage.HandleListScreens(d.TenantStore, d.ScreenStore))))
mux.Handle("POST /api/v1/tenants/{tenantSlug}/screens",
authTenant(http.HandlerFunc(manage.HandleCreateScreen(d.TenantStore, d.ScreenStore))))
// ── JSON API — media ──────────────────────────────────────────────────
mux.Handle("GET /api/v1/tenants/{tenantSlug}/media",
authTenant(http.HandlerFunc(manage.HandleListMedia(d.TenantStore, d.MediaStore))))
mux.Handle("POST /api/v1/tenants/{tenantSlug}/media",
authTenant(http.HandlerFunc(manage.HandleUploadMedia(d.TenantStore, d.MediaStore, uploadDir))))
mux.Handle("DELETE /api/v1/media/{id}",
authOnly(http.HandlerFunc(manage.HandleDeleteMedia(d.MediaStore, uploadDir))))
// ── JSON API — playlists ──────────────────────────────────────────────
// Player fetches its playlist — no auth required.
mux.HandleFunc("GET /api/v1/screens/{screenId}/playlist",
manage.HandlePlayerPlaylist(d.ScreenStore, d.PlaylistStore))
mux.Handle("GET /api/v1/playlists/{screenId}",
authOnly(http.HandlerFunc(manage.HandleGetPlaylist(d.ScreenStore, d.PlaylistStore))))
mux.Handle("POST /api/v1/playlists/{playlistId}/items",
authOnly(http.HandlerFunc(manage.HandleAddItem(d.PlaylistStore, d.MediaStore, notifier))))
mux.Handle("PATCH /api/v1/items/{itemId}",
authOnly(http.HandlerFunc(manage.HandleUpdateItem(d.PlaylistStore, notifier))))
mux.Handle("DELETE /api/v1/items/{itemId}",
authOnly(http.HandlerFunc(manage.HandleDeleteItem(d.PlaylistStore, notifier))))
mux.Handle("PUT /api/v1/playlists/{playlistId}/order",
authOnly(http.HandlerFunc(manage.HandleReorder(d.PlaylistStore, notifier))))
mux.Handle("PATCH /api/v1/playlists/{playlistId}/duration",
authOnly(http.HandlerFunc(manage.HandleUpdatePlaylistDuration(d.PlaylistStore))))
// ── Tenant self-service dashboard ─────────────────────────────────────
mux.Handle("GET /tenant/{tenantSlug}/dashboard",
authTenant(http.HandlerFunc(tenant.HandleTenantDashboard(d.TenantStore, d.ScreenStore, d.MediaStore))))
mux.Handle("POST /tenant/{tenantSlug}/upload",
authTenant(http.HandlerFunc(tenant.HandleTenantUpload(d.TenantStore, d.MediaStore, uploadDir))))
mux.Handle("POST /tenant/{tenantSlug}/media/{mediaId}/delete",
authTenant(http.HandlerFunc(tenant.HandleTenantDeleteMedia(d.TenantStore, d.MediaStore, uploadDir))))
}