From fb8d598e9e62a9f9b8fe9ea086a5fb390e1b51f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Mon, 23 Mar 2026 18:08:32 +0100 Subject: [PATCH] Tenant-Feature Phase 3c + Phase 4: Register-Fix + Tenant-Dashboard UI Phase 3c: - register.go: hardcoded "morz" durch cfg.DefaultTenantSlug ersetzt Phase 4: - neues Package httpapi/tenant: HandleTenantDashboard, HandleTenantUpload, HandleTenantDeleteMedia - tenantDashTmpl: Navbar, zwei Tabs (Monitore/Mediathek), Status-Polling, Upload-Fortschritt - router.go: /tenant/{tenantSlug}/... Routen hinter RequireAuth+RequireTenantAccess - manage/templates.go: Abmelden-Button in Admin-UI und Manage-UI Navbar Co-Authored-By: Claude Sonnet 4.6 --- .../internal/httpapi/manage/register.go | 9 +- .../internal/httpapi/manage/templates.go | 14 + server/backend/internal/httpapi/router.go | 11 +- .../internal/httpapi/tenant/templates.go | 299 ++++++++++++++++++ .../backend/internal/httpapi/tenant/tenant.go | 258 +++++++++++++++ 5 files changed, 586 insertions(+), 5 deletions(-) create mode 100644 server/backend/internal/httpapi/tenant/templates.go create mode 100644 server/backend/internal/httpapi/tenant/tenant.go diff --git a/server/backend/internal/httpapi/manage/register.go b/server/backend/internal/httpapi/manage/register.go index 595ea8e..ee6710b 100644 --- a/server/backend/internal/httpapi/manage/register.go +++ b/server/backend/internal/httpapi/manage/register.go @@ -5,16 +5,17 @@ import ( "net/http" "strings" + "git.az-it.net/az/morz-infoboard/server/backend/internal/config" "git.az-it.net/az/morz-infoboard/server/backend/internal/store" ) // HandleRegisterScreen is called by the player agent on startup. -// It upserts the screen in the default tenant (morz) so that all +// It upserts the screen in the default tenant so that all // deployed screens appear automatically in the admin UI. // // POST /api/v1/screens/register // Body: {"slug":"info10","name":"Info10 Bildschirm","orientation":"landscape"} -func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc { +func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore, cfg config.Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var body struct { Slug string `json:"slug"` @@ -39,8 +40,8 @@ func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore body.Orientation = "landscape" } - // v1: single tenant — always register under "morz". - tenant, err := tenants.Get(r.Context(), "morz") + // Register under the configured default tenant. + tenant, err := tenants.Get(r.Context(), cfg.DefaultTenantSlug) if err != nil { http.Error(w, "default tenant not found", http.StatusInternalServerError) return diff --git a/server/backend/internal/httpapi/manage/templates.go b/server/backend/internal/httpapi/manage/templates.go index 14bd094..ddac2f1 100644 --- a/server/backend/internal/httpapi/manage/templates.go +++ b/server/backend/internal/httpapi/manage/templates.go @@ -250,6 +250,13 @@ const adminTmpl = ` + @@ -538,6 +545,13 @@ const manageTmpl = ` diff --git a/server/backend/internal/httpapi/router.go b/server/backend/internal/httpapi/router.go index 0d74aae..9f36a5b 100644 --- a/server/backend/internal/httpapi/router.go +++ b/server/backend/internal/httpapi/router.go @@ -6,6 +6,7 @@ import ( "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" ) @@ -136,7 +137,7 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) { // ── 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)) + 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", @@ -166,4 +167,12 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) { 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)))) } diff --git a/server/backend/internal/httpapi/tenant/templates.go b/server/backend/internal/httpapi/tenant/templates.go new file mode 100644 index 0000000..323fefd --- /dev/null +++ b/server/backend/internal/httpapi/tenant/templates.go @@ -0,0 +1,299 @@ +package tenant + +const tenantDashTmpl = ` + + + + + Mein Dashboard – morz infoboard + + + + + + + +
+
+ +

{{.Tenant.Name}}

+ + {{if .Flash}} +
+ + {{.Flash}} +
+ {{end}} + +
+ +
+ + +
+
+ {{if .Screens}} +
+ {{range .Screens}} +
+
+
+

+ {{if eq .Orientation "portrait"}}📱{{else}}🖥{{end}} + {{.Name}} +

+

+ {{if eq .Orientation "portrait"}}Hochformat{{else}}Querformat{{end}} +

+
+ Unbekannt +
+
+ +
+
+ {{end}} +
+ {{else}} +

Noch keine Monitore zugewiesen.

+ {{end}} +
+
+ + +
+
+ +

Medium hochladen

+ +
+ +
+ +
+
+ +
+
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+
+ + + +
+
+ +
+
+
+ +
+ +

Vorhandene Medien

+ + {{if .Assets}} +
+ + + + + + + + + + + {{range .Assets}} + + + + + + + {{end}} + +
TypTitelGröße
{{typeIcon .Type}}{{.Title}} + {{if .SizeBytes}}{{humanSize .SizeBytes}}{{else}}–{{end}} + +
+ +
+
+
+ {{else}} +

Noch keine Medien hochgeladen.

+ {{end}} + +
+
+ +
+
+ + + + +` diff --git a/server/backend/internal/httpapi/tenant/tenant.go b/server/backend/internal/httpapi/tenant/tenant.go new file mode 100644 index 0000000..ae3bd49 --- /dev/null +++ b/server/backend/internal/httpapi/tenant/tenant.go @@ -0,0 +1,258 @@ +// Package tenant implements the tenant self-service dashboard UI. +package tenant + +import ( + "fmt" + "html/template" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "git.az-it.net/az/morz-infoboard/server/backend/internal/store" +) + +var tmplFuncs = template.FuncMap{ + "typeIcon": func(t string) string { + switch t { + case "image": + return "🖼" + case "video": + return "🎬" + case "pdf": + return "📄" + case "web": + return "🌐" + default: + return "📁" + } + }, + "humanSize": func(b int64) string { + switch { + case b >= 1<<20: + return fmt.Sprintf("%.1f MB", float64(b)/float64(1<<20)) + case b >= 1<<10: + return fmt.Sprintf("%.0f KB", float64(b)/float64(1<<10)) + default: + return fmt.Sprintf("%d B", b) + } + }, +} + +const maxUploadSize = 512 << 20 // 512 MB + +// HandleTenantDashboard renders the tenant self-service dashboard. +func HandleTenantDashboard( + tenantStore *store.TenantStore, + screenStore *store.ScreenStore, + mediaStore *store.MediaStore, +) http.HandlerFunc { + t := template.Must(template.New("tenant-dash").Funcs(tmplFuncs).Parse(tenantDashTmpl)) + return func(w http.ResponseWriter, r *http.Request) { + tenantSlug := r.PathValue("tenantSlug") + + tenant, err := tenantStore.Get(r.Context(), tenantSlug) + if err != nil { + http.Error(w, "Tenant nicht gefunden: "+tenantSlug, http.StatusNotFound) + return + } + + screens, err := screenStore.List(r.Context(), tenant.ID) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + + assets, err := mediaStore.List(r.Context(), tenant.ID) + if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + + // Flash message: prefer ?flash= (from tenant upload redirect), + // also accept legacy ?msg= used by manage handlers. + flash := "" + if f := r.URL.Query().Get("flash"); f != "" { + switch f { + case "uploaded": + flash = "Medium erfolgreich hochgeladen." + case "deleted": + flash = "Medium erfolgreich gelöscht." + default: + flash = f + } + } else if m := r.URL.Query().Get("msg"); m != "" { + switch m { + case "uploaded": + flash = "Medium erfolgreich hochgeladen." + case "deleted": + flash = "Medium erfolgreich gelöscht." + default: + flash = m + } + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + t.Execute(w, map[string]any{ //nolint:errcheck + "Tenant": tenant, + "Screens": screens, + "Assets": assets, + "Flash": flash, + }) + } +} + +// HandleTenantUpload handles multipart file uploads and web-URL registrations +// from the tenant dashboard, then redirects back. +func HandleTenantUpload( + tenantStore *store.TenantStore, + mediaStore *store.MediaStore, + uploadDir string, +) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tenantSlug := r.PathValue("tenantSlug") + + tenant, err := tenantStore.Get(r.Context(), tenantSlug) + if err != nil { + http.Error(w, "Tenant nicht gefunden: "+tenantSlug, http.StatusNotFound) + return + } + + if err := r.ParseMultipartForm(maxUploadSize); err != nil { + http.Error(w, "Upload zu groß oder ungültig", http.StatusBadRequest) + return + } + + assetType := strings.TrimSpace(r.FormValue("type")) + title := strings.TrimSpace(r.FormValue("title")) + + switch assetType { + case "web": + url := strings.TrimSpace(r.FormValue("url")) + if url == "" { + http.Error(w, "URL erforderlich", http.StatusBadRequest) + return + } + if title == "" { + title = url + } + _, err = mediaStore.Create(r.Context(), tenant.ID, title, "web", "", url, "", 0) + + case "image", "video", "pdf": + file, header, ferr := r.FormFile("file") + if ferr != nil { + http.Error(w, "Datei erforderlich", http.StatusBadRequest) + return + } + defer file.Close() + + if title == "" { + title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)) + } + mimeType := header.Header.Get("Content-Type") + // Derive asset type from MIME if more specific. + if detected := mimeToAssetType(mimeType); detected != "" { + assetType = detected + } + ext := filepath.Ext(header.Filename) + filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitize(title), ext) + destPath := filepath.Join(uploadDir, filename) + + dest, ferr := os.Create(destPath) + if ferr != nil { + http.Error(w, "Speicherfehler", http.StatusInternalServerError) + return + } + defer dest.Close() + + size, cerr := io.Copy(dest, file) + if cerr != nil { + os.Remove(destPath) //nolint:errcheck + http.Error(w, "Schreibfehler", http.StatusInternalServerError) + return + } + storagePath := "/uploads/" + filename + _, err = mediaStore.Create(r.Context(), tenant.ID, title, assetType, storagePath, "", mimeType, size) + + default: + http.Error(w, "Unbekannter Typ", http.StatusBadRequest) + return + } + + if err != nil { + http.Error(w, "DB-Fehler: "+err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/tenant/"+tenantSlug+"/dashboard?tab=media&flash=uploaded", http.StatusSeeOther) + } +} + +// HandleTenantDeleteMedia deletes a media asset owned by the tenant. +func HandleTenantDeleteMedia( + tenantStore *store.TenantStore, + mediaStore *store.MediaStore, + uploadDir string, +) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tenantSlug := r.PathValue("tenantSlug") + mediaID := r.PathValue("mediaId") + + // Verify tenant exists. + tenant, err := tenantStore.Get(r.Context(), tenantSlug) + if err != nil { + http.Error(w, "Tenant nicht gefunden", http.StatusNotFound) + return + } + + asset, err := mediaStore.Get(r.Context(), mediaID) + if err != nil { + http.Error(w, "Medium nicht gefunden", http.StatusNotFound) + return + } + // Ownership check. + if asset.TenantID != tenant.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + if asset.StoragePath != "" { + os.Remove(filepath.Join(uploadDir, filepath.Base(asset.StoragePath))) //nolint:errcheck + } + mediaStore.Delete(r.Context(), mediaID) //nolint:errcheck + + http.Redirect(w, r, "/tenant/"+tenantSlug+"/dashboard?tab=media&flash=deleted", http.StatusSeeOther) + } +} + +// mimeToAssetType derives the asset type from a MIME type string. +func mimeToAssetType(mime string) string { + mime = strings.ToLower(strings.TrimSpace(mime)) + switch { + case strings.HasPrefix(mime, "image/"): + return "image" + case strings.HasPrefix(mime, "video/"): + return "video" + case mime == "application/pdf": + return "pdf" + default: + return "" + } +} + +// sanitize converts a string to a safe filename component. +func sanitize(s string) string { + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + b.WriteRune(r) + } else { + b.WriteRune('_') + } + } + out := b.String() + if len(out) > 40 { + out = out[:40] + } + return out +}