Neue Rolle screen_user: User können sich einloggen und nur ihre
zugeordneten Bildschirme verwalten. Admins behalten vollen Zugriff.
- Migration 003: users.role-Spalte + user_screen_permissions (M:N)
- Store: CreateScreenUser, ListScreenUsers, DeleteUser,
GetAccessibleScreens, HasUserScreenAccess,
AddUserToScreen, RemoveUserFromScreen, GetScreenUsers
- Middleware: RequireScreenAccess enforces screen-level access
für alle /manage/{screenSlug}-Routen
- 4 neue Admin-Handler: CreateScreenUser, DeleteScreenUser,
AddUserToScreen, RemoveUserFromScreen (+4 Routes)
- Admin-UI: Tab "Benutzer" (anlegen/löschen) + Screen-User-Modal
(User zuordnen/entfernen) direkt in der Bildschirm-Tabelle
- Login: screen_user wird nach Login zum ersten zugänglichen Screen
weitergeleitet; kein Zugang zu /admin
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
210 lines
11 KiB
Go
210 lines
11 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. 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))))
|
|
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/{screenSlug}",
|
|
authScreen(http.HandlerFunc(manage.HandleManageUI(d.TenantStore, d.ScreenStore, d.MediaStore, d.PlaylistStore))))
|
|
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))))
|
|
|
|
// ── 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))))
|
|
}
|