// Package tenant implements the tenant self-service dashboard UI. package tenant import ( "bytes" "fmt" "html/template" "net/http" "os" "path/filepath" "strings" "git.az-it.net/az/morz-infoboard/server/backend/internal/config" "git.az-it.net/az/morz-infoboard/server/backend/internal/fileutil" "git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext" "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) } }, "orientationLabel": func(o string) string { if o == "portrait" { return "Hochformat" } return "Querformat" }, } const maxUploadSize = 512 << 20 // 512 MB // HandleTenantDashboard renders the tenant self-service dashboard. func HandleTenantDashboard( tenantStore *store.TenantStore, screenStore *store.ScreenStore, mediaStore *store.MediaStore, cfg config.Config, ) 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 } } csrfToken := setCSRFCookie(w, r, cfg.DevMode) // W7: Template in Buffer rendern, erst bei Erfolg an Client senden. var buf bytes.Buffer if err := t.Execute(&buf, map[string]any{ "Tenant": tenant, "Screens": screens, "Assets": assets, "Flash": flash, "CSRFToken": csrfToken, }); err != nil { http.Error(w, "Interner Fehler (Template)", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") buf.WriteTo(w) //nolint:errcheck } } // 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 } // W3: MaxBytesReader begrenzt Uploads auf maxUploadSize bevor ParseMultipartForm. r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) 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")) createdByUserID := "" if u := reqcontext.UserFromContext(r.Context()); u != nil { createdByUserID = u.ID } 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, "", createdByUserID, 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 } // V1+N6: tenant-spezifisches Upload-Verzeichnis. storagePath, size, cerr := fileutil.SaveUploadedFile(file, header.Filename, title, uploadDir, tenantSlug) if cerr != nil { http.Error(w, "Speicherfehler", http.StatusInternalServerError) return } _, err = mediaStore.Create(r.Context(), tenant.ID, title, assetType, storagePath, "", mimeType, createdByUserID, size) default: http.Error(w, "Unbekannter Typ", http.StatusBadRequest) return } if err != nil { http.Error(w, "Interner Fehler", 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 }