morz-infoboard/server/backend/internal/httpapi/manage/ui.go
Jesko Anschütz d1d86126c8 Feature: Screen-User-Verwaltung mit rollenbasiertem Zugriff
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>
2026-03-23 22:06:05 +01:00

711 lines
22 KiB
Go

package manage
import (
"bytes"
"encoding/json"
"html/template"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/fileutil"
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
"git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
// jsonSafe serializes v to a JSON string safe for inline use in a <script> block.
// It returns template.JS so the template engine does not HTML-escape it again.
func jsonSafe(v any) template.JS {
b, err := json.Marshal(v)
if err != nil {
return template.JS("null")
}
return template.JS(b) //nolint:gosec
}
// renderTemplate rendert t mit data in einen Buffer und schreibt das Ergebnis erst
// dann in w, wenn kein Fehler aufgetreten ist. W7: Verhindert halb-gerendertes HTML.
func renderTemplate(w http.ResponseWriter, t *template.Template, data any) {
var buf bytes.Buffer
if err := t.Execute(&buf, data); 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
}
// requireScreenAccess prüft, ob der eingeloggte User Zugriff auf den Screen hat.
// Admins dürfen alles. Tenant-User dürfen nur Screens ihres eigenen Tenants bearbeiten.
// Gibt true zurück wenn Zugriff erlaubt ist; schreibt 403 und gibt false zurück wenn nicht.
func requireScreenAccess(w http.ResponseWriter, r *http.Request, screen *store.Screen) bool {
u := reqcontext.UserFromContext(r.Context())
if u == nil {
http.Error(w, "Forbidden", http.StatusForbidden)
return false
}
if u.Role == "admin" {
return true
}
// Tenant-User: Screen muss zum eigenen Tenant gehören.
// Wir vergleichen über TenantSlug→TenantID, aber der Screen hat TenantID.
// Da uns der Tenant-Slug des Users bekannt ist und wir keinen TenantStore
// hier haben, vergleichen wir TenantID des Screens mit dem user.TenantID-Feld.
// store.User hat TenantSlug aber nicht TenantID — deswegen muss der
// aufrufende Handler nach GetBySlug bereits die TenantID des Screens bekannt haben.
// Wir nutzen u.TenantSlug und vertrauen darauf dass der Screen bereits geladen ist.
// Den eigentlichen Vergleich machen wir via TenantID des Screens vs.
// dem TenantID-Feld im User (das über reqcontext gespeichert ist).
if u.TenantID != "" && u.TenantID != screen.TenantID {
http.Error(w, "Forbidden", http.StatusForbidden)
return false
}
return true
}
var tmplFuncs = template.FuncMap{
// screenUsersJSON serializes a []*store.User slice to JSON for inline JS.
"screenUsersJSON": func(users []*store.User) template.JS {
type entry struct {
ID string `json:"id"`
Username string `json:"username"`
}
out := make([]entry, 0, len(users))
for _, u := range users {
out = append(out, entry{ID: u.ID, Username: u.Username})
}
return jsonSafe(out)
},
// screenUserMapJSON serializes map[string][]*store.ScreenUserEntry to JSON.
"screenUserMapJSON": func(m map[string][]*store.ScreenUserEntry) template.JS {
type entry struct {
ID string `json:"id"`
Username string `json:"username"`
}
out := map[string][]entry{}
for screenID, users := range m {
entries := make([]entry, 0, len(users))
for _, u := range users {
entries = append(entries, entry{ID: u.ID, Username: u.Username})
}
out[screenID] = entries
}
return jsonSafe(out)
},
"typeIcon": func(t string) string {
switch t {
case "image":
return "🖼"
case "video":
return "🎬"
case "pdf":
return "📄"
case "web":
return "🌐"
default:
return "📁"
}
},
"orientationLabel": func(o string) string {
if o == "portrait" {
return "Hochformat"
}
return "Querformat"
},
"shortSrc": func(s string) string {
if len(s) > 60 {
return s[:57] + "..."
}
return s
},
"formatDT": func(t *time.Time) string {
if t == nil {
return ""
}
return t.Format("2006-01-02T15:04")
},
}
// HandleAdminUI renders the admin overview page (screens + users tabs).
func HandleAdminUI(tenants *store.TenantStore, screens *store.ScreenStore, auth *store.AuthStore) http.HandlerFunc {
t := template.Must(template.New("admin").Funcs(tmplFuncs).Parse(adminTmpl))
return func(w http.ResponseWriter, r *http.Request) {
allScreens, err := screens.ListAll(r.Context())
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
allTenants, err := tenants.List(r.Context())
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
// Default tenant slug for user management.
tenantSlug := "morz"
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
tenantSlug = u.TenantSlug
}
screenUsers, err := auth.ListScreenUsers(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
// Build per-screen user lists for the modal.
screenUserMap := map[string][]*store.ScreenUserEntry{}
for _, sc := range allScreens {
users, err := screens.GetScreenUsers(r.Context(), sc.ID)
if err != nil {
continue
}
screenUserMap[sc.ID] = users
}
activeTab := r.URL.Query().Get("tab")
if activeTab == "" {
activeTab = "screens"
}
renderTemplate(w, t, map[string]any{
"Screens": allScreens,
"Tenants": allTenants,
"ScreenUsers": screenUsers,
"ScreenUserMap": screenUserMap,
"ActiveTab": activeTab,
})
}
}
// HandleCreateScreenUser creates a new screen_user for the default tenant.
func HandleCreateScreenUser(auth *store.AuthStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
if username == "" || password == "" {
http.Redirect(w, r, "/admin?tab=users&msg=error_empty", http.StatusSeeOther)
return
}
tenantSlug := "morz"
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
tenantSlug = u.TenantSlug
}
_, err := auth.CreateScreenUser(r.Context(), tenantSlug, username, password)
if err != nil {
http.Redirect(w, r, "/admin?tab=users&msg=error_exists", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin?tab=users&msg=user_added", http.StatusSeeOther)
}
}
// HandleDeleteScreenUser deletes a screen_user by ID.
func HandleDeleteScreenUser(auth *store.AuthStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("userID")
if err := auth.DeleteUser(r.Context(), userID); err != nil {
http.Error(w, "Fehler beim Löschen", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin?tab=users&msg=user_deleted", http.StatusSeeOther)
}
}
// HandleAddUserToScreen grants a user access to a specific screen.
func HandleAddUserToScreen(screens *store.ScreenStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenID := r.PathValue("screenID")
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
userID := strings.TrimSpace(r.FormValue("user_id"))
if userID == "" {
http.Redirect(w, r, "/admin?msg=error_empty", http.StatusSeeOther)
return
}
if err := screens.AddUserToScreen(r.Context(), userID, screenID); err != nil {
http.Redirect(w, r, "/admin?msg=error_db", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin?screen="+screenID+"&msg=user_added_to_screen", http.StatusSeeOther)
}
}
// HandleRemoveUserFromScreen removes a user's access to a specific screen.
func HandleRemoveUserFromScreen(screens *store.ScreenStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenID := r.PathValue("screenID")
userID := r.PathValue("userID")
if err := screens.RemoveUserFromScreen(r.Context(), userID, screenID); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin?screen="+screenID+"&msg=user_removed_from_screen", http.StatusSeeOther)
}
}
// HandleManageUI renders the playlist management UI for a specific screen.
func HandleManageUI(
tenants *store.TenantStore,
screens *store.ScreenStore,
media *store.MediaStore,
playlists *store.PlaylistStore,
) http.HandlerFunc {
t := template.Must(template.New("manage").Funcs(tmplFuncs).Parse(manageTmpl))
return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug")
screen, err := screens.GetBySlug(r.Context(), screenSlug)
if err != nil {
http.Error(w, "Screen nicht gefunden: "+screenSlug, http.StatusNotFound)
return
}
// K2: Tenant-Isolation — nur eigener Tenant oder Admin.
if !requireScreenAccess(w, r, screen) {
return
}
var tenant *store.Tenant
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
tenant, _ = tenants.Get(r.Context(), u.TenantSlug)
}
if tenant == nil {
tenant = &store.Tenant{ID: screen.TenantID, Name: "MORZ"}
}
playlist, err := playlists.GetOrCreateForScreen(r.Context(), screen.TenantID, screen.ID, screen.Name)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
items, err := playlists.ListItems(r.Context(), playlist.ID)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
assets, err := media.List(r.Context(), screen.TenantID)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
// Build set of already-added asset IDs to mark them in library.
addedAssets := map[string]bool{}
for _, it := range items {
if it.MediaAssetID != "" {
addedAssets[it.MediaAssetID] = true
}
}
// Determine back-navigation based on ?from= query parameter.
backLink := "/admin"
backLabel := "← Admin"
if r.URL.Query().Get("from") == "tenant" {
tenantSlug := ""
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
tenantSlug = u.TenantSlug
}
if tenantSlug != "" {
backLink = "/tenant/" + tenantSlug + "/dashboard"
backLabel = "← Dashboard"
}
}
renderTemplate(w, t, map[string]any{
"Screen": screen,
"Tenant": tenant,
"Playlist": playlist,
"Items": items,
"Assets": assets,
"AddedAssets": addedAssets,
"BackLink": backLink,
"BackLabel": backLabel,
})
}
}
// HandleCreateScreenUI handles form POST to create a screen, then redirects.
func HandleCreateScreenUI(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
slug := strings.TrimSpace(r.FormValue("slug"))
name := strings.TrimSpace(r.FormValue("name"))
orientation := r.FormValue("orientation")
if slug == "" || name == "" {
http.Error(w, "slug und name erforderlich", http.StatusBadRequest)
return
}
if orientation == "" {
orientation = "landscape"
}
tenantSlug := "morz"
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
tenantSlug = u.TenantSlug
}
tenant, err := tenants.Get(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "tenant nicht gefunden", http.StatusInternalServerError)
return
}
_, err = screens.Create(r.Context(), tenant.ID, slug, name, orientation)
if err != nil {
http.Error(w, "Interner Fehler", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin?msg=added", http.StatusSeeOther)
}
}
// HandleProvisionUI creates a screen in DB and shows the Ansible setup instructions.
func HandleProvisionUI(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
t := template.Must(template.New("provision").Funcs(tmplFuncs).Parse(provisionTmpl))
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
slug := strings.TrimSpace(r.FormValue("slug"))
name := strings.TrimSpace(r.FormValue("name"))
ip := strings.TrimSpace(r.FormValue("ip"))
sshUser := strings.TrimSpace(r.FormValue("ssh_user"))
orientation := r.FormValue("orientation")
if slug == "" || ip == "" {
http.Error(w, "slug und IP-Adresse erforderlich", http.StatusBadRequest)
return
}
if name == "" {
name = slug
}
if sshUser == "" {
sshUser = "morz"
}
if orientation == "" {
orientation = "landscape"
}
tenantSlug := "morz"
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
tenantSlug = u.TenantSlug
}
tenant, err := tenants.Get(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "tenant nicht gefunden", http.StatusInternalServerError)
return
}
screen, err := screens.Upsert(r.Context(), tenant.ID, slug, name, orientation)
if err != nil {
http.Error(w, "Interner Fehler", http.StatusInternalServerError)
return
}
renderTemplate(w, t, map[string]any{
"Screen": screen,
"IP": ip,
"SSHUser": sshUser,
"Orientation": orientation,
})
}
}
// HandleDeleteScreenUI handles DELETE for a screen, then redirects.
func HandleDeleteScreenUI(screens *store.ScreenStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("screenId")
if err := screens.Delete(r.Context(), id); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin?msg=deleted", http.StatusSeeOther)
}
}
// HandleUploadMediaUI handles form upload from the manage UI and redirects back.
func HandleUploadMediaUI(media *store.MediaStore, screens *store.ScreenStore, uploadDir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug")
screen, err := screens.GetBySlug(r.Context(), screenSlug)
if err != nil {
http.Error(w, "screen nicht gefunden", http.StatusNotFound)
return
}
// K2: Tenant-Isolation.
if !requireScreenAccess(w, r, screen) {
return
}
// W3: MaxBytesReader begrenzt Uploads auf maxUploadSize.
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"))
// Bestimme tenantSlug für N6 (tenant-spezifisches Upload-Verzeichnis).
tenantSlug := ""
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
tenantSlug = u.TenantSlug
}
if tenantSlug == "" {
tenantSlug = "default"
}
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 = media.Create(r.Context(), screen.TenantID, 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")
// V1+N6: Gemeinsame Upload-Funktion, tenant-spezifisches Verzeichnis.
storagePath, size, ferr := fileutil.SaveUploadedFile(file, header.Filename, title, uploadDir, tenantSlug)
if ferr != nil {
http.Error(w, "Speicherfehler", http.StatusInternalServerError)
return
}
_, err = media.Create(r.Context(), screen.TenantID, title, assetType, storagePath, "", mimeType, size)
default:
http.Error(w, "Unbekannter Typ", http.StatusBadRequest)
return
}
if err != nil {
http.Error(w, "DB-Fehler", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/manage/"+screenSlug+"?msg=uploaded", http.StatusSeeOther)
}
}
// HandleAddItemUI handles form POST to add a playlist item, then redirects.
func HandleAddItemUI(playlists *store.PlaylistStore, media *store.MediaStore, screens *store.ScreenStore, notifier *mqttnotifier.Notifier) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug")
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
screen, err := screens.GetBySlug(r.Context(), screenSlug)
if err != nil {
http.Error(w, "screen nicht gefunden", http.StatusNotFound)
return
}
// K2: Tenant-Isolation.
if !requireScreenAccess(w, r, screen) {
return
}
playlist, err := playlists.GetOrCreateForScreen(r.Context(), screen.TenantID, screen.ID, screen.Name)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
mediaAssetID := r.FormValue("media_asset_id")
itemType := r.FormValue("type")
src := r.FormValue("src")
title := r.FormValue("title")
durationSeconds := 20
if d, err := strconv.Atoi(strings.TrimSpace(r.FormValue("duration_seconds"))); err == nil && d > 0 {
durationSeconds = d
}
validFrom, _ := parseOptionalTime(r.FormValue("valid_from"))
validUntil, _ := parseOptionalTime(r.FormValue("valid_until"))
if mediaAssetID != "" {
asset, err := media.Get(r.Context(), mediaAssetID)
if err != nil {
http.Error(w, "Medium nicht gefunden", http.StatusBadRequest)
return
}
itemType = asset.Type
if asset.StoragePath != "" {
src = asset.StoragePath
} else {
src = asset.OriginalURL
}
if title == "" {
title = asset.Title
}
}
if itemType == "" || src == "" {
http.Error(w, "type und src erforderlich", http.StatusBadRequest)
return
}
_, err = playlists.AddItem(r.Context(), playlist.ID, mediaAssetID,
itemType, src, title, durationSeconds, validFrom, validUntil)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
notifier.NotifyChanged(screenSlug)
http.Redirect(w, r, "/manage/"+screenSlug+"?msg=added", http.StatusSeeOther)
}
}
// HandleDeleteItemUI removes a playlist item and redirects back.
func HandleDeleteItemUI(playlists *store.PlaylistStore, screens *store.ScreenStore, notifier *mqttnotifier.Notifier) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug")
itemID := r.PathValue("itemId")
// K2: Tenant-Isolation.
screen, err := screens.GetBySlug(r.Context(), screenSlug)
if err != nil {
http.Error(w, "screen nicht gefunden", http.StatusNotFound)
return
}
if !requireScreenAccess(w, r, screen) {
return
}
if err := playlists.DeleteItem(r.Context(), itemID); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
notifier.NotifyChanged(screenSlug)
http.Redirect(w, r, "/manage/"+screenSlug+"?msg=deleted", http.StatusSeeOther)
}
}
// HandleReorderUI accepts JSON body with ordered IDs (HTMX/fetch).
func HandleReorderUI(playlists *store.PlaylistStore, screens *store.ScreenStore, notifier *mqttnotifier.Notifier) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug")
screen, err := screens.GetBySlug(r.Context(), screenSlug)
if err != nil {
http.Error(w, "screen nicht gefunden", http.StatusNotFound)
return
}
// K2: Tenant-Isolation.
if !requireScreenAccess(w, r, screen) {
return
}
playlist, err := playlists.GetByScreen(r.Context(), screen.ID)
if err != nil {
http.Error(w, "playlist nicht gefunden", http.StatusNotFound)
return
}
var ids []string
if err := json.NewDecoder(r.Body).Decode(&ids); err != nil {
http.Error(w, "JSON erwartet: array von item-IDs", http.StatusBadRequest)
return
}
if err := playlists.Reorder(r.Context(), playlist.ID, ids); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
notifier.NotifyChanged(screenSlug)
w.WriteHeader(http.StatusNoContent)
}
}
// HandleUpdateItemUI handles form PATCH/POST to update a single item.
func HandleUpdateItemUI(playlists *store.PlaylistStore, screens *store.ScreenStore, notifier *mqttnotifier.Notifier) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug")
itemID := r.PathValue("itemId")
// K2: Tenant-Isolation.
screen, err := screens.GetBySlug(r.Context(), screenSlug)
if err != nil {
http.Error(w, "screen nicht gefunden", http.StatusNotFound)
return
}
if !requireScreenAccess(w, r, screen) {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
title := r.FormValue("title")
durationSeconds := 20
if d, err := strconv.Atoi(strings.TrimSpace(r.FormValue("duration_seconds"))); err == nil && d > 0 {
durationSeconds = d
}
enabled := r.FormValue("enabled") == "true"
validFrom, _ := parseOptionalTime(r.FormValue("valid_from"))
validUntil, _ := parseOptionalTime(r.FormValue("valid_until"))
if err := playlists.UpdateItem(r.Context(), itemID, title, durationSeconds, enabled, validFrom, validUntil); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
notifier.NotifyChanged(screenSlug)
http.Redirect(w, r, "/manage/"+screenSlug+"?msg=saved", http.StatusSeeOther)
}
}
// HandleDeleteMediaUI deletes media and redirects back.
func HandleDeleteMediaUI(media *store.MediaStore, screens *store.ScreenStore, uploadDir string, notifier *mqttnotifier.Notifier) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug")
mediaID := r.PathValue("mediaId")
// K2: Tenant-Isolation.
screen, err := screens.GetBySlug(r.Context(), screenSlug)
if err != nil {
http.Error(w, "screen nicht gefunden", http.StatusNotFound)
return
}
if !requireScreenAccess(w, r, screen) {
return
}
asset, err := media.Get(r.Context(), mediaID)
if err == nil && asset.StoragePath != "" {
os.Remove(filepath.Join(uploadDir, filepath.Base(asset.StoragePath))) //nolint:errcheck
}
media.Delete(r.Context(), mediaID) //nolint:errcheck
notifier.NotifyChanged(screenSlug)
http.Redirect(w, r, "/manage/"+screenSlug+"?msg=deleted", http.StatusSeeOther)
}
}