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 ScreenshotStore *ScreenshotStore ScheduleStore *store.ScreenScheduleStore GlobalOverrideStore *store.GlobalOverrideStore 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", }) }) // ── Root redirect ──────────────────────────────────────────────────── mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } http.Redirect(w, r, "/login", http.StatusSeeOther) }) // ── 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, deps.ScreenStore, deps.Config.MQTTBroker, deps.Config.MQTTUsername, deps.Config.MQTTPassword)) mux.HandleFunc("POST /api/v1/player/screenshot", handlePlayerScreenshot(deps.ScreenshotStore)) 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. N5: Directory-Listing deaktiviert via neuteredFileSystem. mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", http.FileServer(neuteredFileSystem{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()) // K1: CSRF-Schutz für alle state-ändernden Routen. csrf := CSRFProtect(d.Config.DevMode) // K1: Setzt den CSRF-Cookie bei GET-Requests, damit das JS-Inject-Script ihn lesen kann. setCSRF := func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { SetCSRFCookie(w, r, d.Config.DevMode) } h.ServeHTTP(w, r) }) } // ── Auth (no auth middleware required) ──────────────────────────────── // K1: GET /login setzt CSRF-Cookie; POST /login und POST /logout werden per CSRF geprüft. mux.Handle("GET /login", http.HandlerFunc(manage.HandleLoginUI(d.AuthStore, d.ScreenStore, d.Config))) // N1: Rate-Limiting auf /login (max. 5 Versuche/Minute pro IP). mux.Handle("POST /login", RateLimitLogin(csrf(http.HandlerFunc(manage.HandleLoginPost(d.AuthStore, d.ScreenStore, d.Config))))) mux.Handle("POST /logout", csrf(http.HandlerFunc(manage.HandleLogoutPost(d.AuthStore, d.Config)))) // Shorthand middleware combinators for this router. // Für GET-Routen: setCSRF setzt den Cookie; für POST-Routen: csrf validiert ihn. authOnly := func(h http.Handler) http.Handler { return chain(h, RequireAuth(d.AuthStore), setCSRF, csrf) } authAdmin := func(h http.Handler) http.Handler { return chain(h, RequireAuth(d.AuthStore), RequireAdmin, setCSRF, csrf) } authTenant := func(h http.Handler) http.Handler { return chain(h, RequireAuth(d.AuthStore), RequireTenantAccess, setCSRF, csrf) } // authScreen: wie authOnly, aber zusätzlich Screen-Zugriffsprüfung für screen_user. // Admins und Tenant-User werden von RequireScreenAccess durchgelassen. authScreen := func(h http.Handler) http.Handler { return chain(h, RequireAuth(d.AuthStore), RequireScreenAccess(d.ScreenStore), setCSRF, csrf) } // ── Admin UI ────────────────────────────────────────────────────────── mux.Handle("GET /admin", authAdmin(http.HandlerFunc(manage.HandleAdminUI(d.TenantStore, d.ScreenStore, d.AuthStore, d.Config)))) 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)))) // ── Screen-User-Verwaltung (nur Admin) ──────────────────────────────── mux.Handle("POST /admin/users", authAdmin(http.HandlerFunc(manage.HandleCreateScreenUser(d.AuthStore)))) mux.Handle("POST /admin/users/{userID}/delete", authAdmin(http.HandlerFunc(manage.HandleDeleteScreenUser(d.AuthStore)))) mux.Handle("POST /admin/screens/{screenID}/users", authAdmin(http.HandlerFunc(manage.HandleAddUserToScreen(d.ScreenStore)))) mux.Handle("POST /admin/screens/{screenID}/users/{userID}/remove", authAdmin(http.HandlerFunc(manage.HandleRemoveUserFromScreen(d.ScreenStore)))) // ── Playlist management UI ──────────────────────────────────────────── // authScreen enforces that screen_user only accesses their permitted screens. mux.Handle("GET /manage", authOnly(http.HandlerFunc(manage.HandleScreenOverview(d.ScreenStore, notifier, d.Config)))) mux.Handle("GET /manage/{screenSlug}", authScreen(http.HandlerFunc(manage.HandleManageUI(d.TenantStore, d.ScreenStore, d.ScheduleStore, d.MediaStore, d.PlaylistStore, d.Config, notifier)))) mux.Handle("POST /manage/{screenSlug}/upload", authScreen(http.HandlerFunc(manage.HandleUploadMediaUI(d.MediaStore, d.ScreenStore, uploadDir)))) mux.Handle("POST /manage/{screenSlug}/items", authScreen(http.HandlerFunc(manage.HandleAddItemUI(d.PlaylistStore, d.MediaStore, d.ScreenStore, notifier)))) mux.Handle("POST /manage/{screenSlug}/items/{itemId}", authScreen(http.HandlerFunc(manage.HandleUpdateItemUI(d.PlaylistStore, d.ScreenStore, notifier)))) mux.Handle("POST /manage/{screenSlug}/items/{itemId}/delete", authScreen(http.HandlerFunc(manage.HandleDeleteItemUI(d.PlaylistStore, d.ScreenStore, notifier)))) mux.Handle("POST /manage/{screenSlug}/reorder", authScreen(http.HandlerFunc(manage.HandleReorderUI(d.PlaylistStore, d.ScreenStore, notifier)))) mux.Handle("POST /manage/{screenSlug}/media/{mediaId}/delete", authScreen(http.HandlerFunc(manage.HandleDeleteMediaUI(d.MediaStore, d.ScreenStore, uploadDir, notifier)))) // ── Screenshot API ──────────────────────────────────────────────────── mux.Handle("GET /api/v1/screens/{screenId}/screenshot", authOnly(http.HandlerFunc(handleGetScreenshot(d.ScreenshotStore)))) // ── Display control ─────────────────────────────────────────────────── mux.Handle("POST /api/v1/screens/{screenSlug}/display", authScreen(http.HandlerFunc(manage.HandleDisplayCommand(notifier)))) // ── Schedule control ────────────────────────────────────────────────── mux.Handle("POST /api/v1/screens/{screenSlug}/schedule", authScreen(http.HandlerFunc(manage.HandleUpdateSchedule(d.ScreenStore, d.ScheduleStore)))) // ── Globaler Override ──────────────────────────────────────────────── mux.Handle("GET /api/v1/global-override", authOnly(http.HandlerFunc(manage.HandleGetGlobalOverride(d.GlobalOverrideStore)))) mux.Handle("POST /api/v1/global-override", authOnly(http.HandlerFunc(manage.HandleSetGlobalOverride(d.GlobalOverrideStore, d.ScreenStore, notifier)))) mux.Handle("DELETE /api/v1/global-override", authOnly(http.HandlerFunc(manage.HandleDeleteGlobalOverride(d.GlobalOverrideStore)))) // ── Per-Screen Override ─────────────────────────────────────────────── mux.Handle("POST /api/v1/screens/{screenSlug}/override", authScreen(http.HandlerFunc(manage.HandleSetScreenOverride(d.ScreenStore, d.ScheduleStore)))) // ── 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, d.Config)))) 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)))) }