871 lines
28 KiB
Go
871 lines
28 KiB
Go
package manage
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"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/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")
|
|
},
|
|
"formatDateDE": func(t *time.Time) string {
|
|
if t == nil {
|
|
return ""
|
|
}
|
|
return t.Format("02.01.2006 15:04")
|
|
},
|
|
"not_expired": func(t *time.Time) bool {
|
|
return t != nil && time.Now().Before(*t)
|
|
},
|
|
}
|
|
|
|
// HandleAdminUI renders the admin overview page (screens + users tabs).
|
|
func HandleAdminUI(tenants *store.TenantStore, screens *store.ScreenStore, auth *store.AuthStore, cfg config.Config) 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"
|
|
}
|
|
|
|
// M2: ?screen= Parameter weitergeben damit das Template den Screen-Modal öffnen kann.
|
|
autoOpenScreen := r.URL.Query().Get("screen")
|
|
|
|
// M6: CSRF-Token an Template-Daten weitergeben.
|
|
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
|
|
|
|
renderTemplate(w, t, map[string]any{
|
|
"Screens": allScreens,
|
|
"Tenants": allTenants,
|
|
"ScreenUsers": screenUsers,
|
|
"ScreenUserMap": screenUserMap,
|
|
"ActiveTab": activeTab,
|
|
"AutoOpenScreen": autoOpenScreen,
|
|
"CSRFToken": csrfToken,
|
|
})
|
|
}
|
|
}
|
|
|
|
// HandleCreateScreenUser creates a new screen user (role: screen_user or restricted) 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
|
|
}
|
|
|
|
role := r.FormValue("role")
|
|
if role != "screen_user" && role != "restricted" {
|
|
role = "screen_user"
|
|
}
|
|
|
|
tenantSlug := "morz"
|
|
if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" {
|
|
tenantSlug = u.TenantSlug
|
|
}
|
|
|
|
_, err := auth.CreateScreenUser(r.Context(), tenantSlug, username, password, role)
|
|
if err != nil {
|
|
slog.Error("create screen user failed", "event", "create_screen_user_failed",
|
|
"tenant_slug", tenantSlug, "username", username, "err", err)
|
|
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 {
|
|
slog.Error("delete screen user failed", "event", "delete_screen_user_failed",
|
|
"user_id", userID, "err", err)
|
|
http.Redirect(w, r, "/admin?tab=users&msg=error_db", http.StatusSeeOther)
|
|
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?tab=users&screen="+screenID+"&msg=error_empty", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if err := screens.AddUserToScreen(r.Context(), userID, screenID); err != nil {
|
|
slog.Error("add user to screen failed", "event", "add_user_to_screen_failed",
|
|
"screen_id", screenID, "user_id", userID, "err", err)
|
|
http.Redirect(w, r, "/admin?tab=users&screen="+screenID+"&msg=error_db", http.StatusSeeOther)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/admin?tab=users&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 {
|
|
slog.Error("remove user from screen failed", "event", "remove_user_from_screen_failed",
|
|
"screen_id", screenID, "user_id", userID, "err", err)
|
|
http.Redirect(w, r, "/admin?tab=users&screen="+screenID+"&msg=error_db", http.StatusSeeOther)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/admin?tab=users&screen="+screenID+"&msg=user_removed_from_screen", http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
type screenCard struct {
|
|
Screen *store.Screen
|
|
DisplayState string
|
|
OverrideOnUntil *time.Time
|
|
}
|
|
|
|
// HandleScreenOverview renders a card-based overview of all accessible screens for a screen_user.
|
|
func HandleScreenOverview(screens *store.ScreenStore, schedules *store.ScreenScheduleStore, overrides *store.GlobalOverrideStore, notifier *mqttnotifier.Notifier, cfg config.Config) http.HandlerFunc {
|
|
t := template.Must(template.New("screenOverview").Funcs(tmplFuncs).Parse(screenOverviewTmpl))
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
u := reqcontext.UserFromContext(r.Context())
|
|
if u == nil {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
var accessible []*store.Screen
|
|
var err error
|
|
if u.Role == "admin" {
|
|
accessible, err = screens.ListAll(r.Context())
|
|
} else {
|
|
accessible, err = screens.GetAccessibleScreens(r.Context(), u.ID)
|
|
}
|
|
if err != nil || len(accessible) == 0 {
|
|
http.Redirect(w, r, "/login?error=no_screens", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if len(accessible) == 1 {
|
|
http.Redirect(w, r, "/manage/"+accessible[0].Slug, http.StatusSeeOther)
|
|
return
|
|
}
|
|
for _, sc := range accessible {
|
|
notifier.RequestScreenshot(sc.Slug)
|
|
}
|
|
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
|
|
cards := make([]screenCard, 0, len(accessible))
|
|
for _, sc := range accessible {
|
|
ds, _ := screens.GetDisplayState(r.Context(), sc.ID)
|
|
sched, _ := schedules.Get(r.Context(), sc.ID)
|
|
var overrideOnUntil *time.Time
|
|
if sched != nil && sched.OverrideOnUntil != nil && time.Now().Before(*sched.OverrideOnUntil) {
|
|
overrideOnUntil = sched.OverrideOnUntil
|
|
}
|
|
cards = append(cards, screenCard{Screen: sc, DisplayState: ds, OverrideOnUntil: overrideOnUntil})
|
|
}
|
|
|
|
var activeOverride *store.GlobalOverride
|
|
if o, err := overrides.Get(r.Context()); err == nil && o != nil && time.Now().Before(o.Until) {
|
|
activeOverride = o
|
|
}
|
|
|
|
renderTemplate(w, t, map[string]any{
|
|
"Cards": cards,
|
|
"CSRFToken": csrfToken,
|
|
"GlobalOverride": activeOverride,
|
|
"UserRole": u.Role,
|
|
})
|
|
}
|
|
}
|
|
|
|
// HandleManageUI renders the playlist management UI for a specific screen.
|
|
func HandleManageUI(
|
|
tenants *store.TenantStore,
|
|
screens *store.ScreenStore,
|
|
schedules *store.ScreenScheduleStore,
|
|
media *store.MediaStore,
|
|
playlists *store.PlaylistStore,
|
|
cfg config.Config,
|
|
notifier *mqttnotifier.Notifier,
|
|
) 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
|
|
}
|
|
|
|
notifier.RequestScreenshot(screen.Slug)
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// M6: CSRF-Token an Template-Daten weitergeben.
|
|
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
|
|
|
|
displayState, _ := screens.GetDisplayState(r.Context(), screen.ID)
|
|
|
|
schedule, _ := schedules.Get(r.Context(), screen.ID)
|
|
if schedule == nil {
|
|
schedule = &store.ScreenSchedule{ScreenID: screen.ID}
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
}
|
|
|
|
isAdmin := false
|
|
var accessibleScreens []*store.Screen
|
|
if u := reqcontext.UserFromContext(r.Context()); u != nil {
|
|
switch u.Role {
|
|
case "admin":
|
|
isAdmin = true
|
|
accessibleScreens, _ = screens.ListAll(r.Context())
|
|
case "screen_user", "restricted":
|
|
accessibleScreens, _ = screens.GetAccessibleScreens(r.Context(), u.ID)
|
|
default:
|
|
// tenant_user und ähnliche Rollen: alle Screens des eigenen Tenants.
|
|
if u.TenantID != "" {
|
|
accessibleScreens, _ = screens.List(r.Context(), u.TenantID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// M5: Server-Timezone ermitteln — bevorzugt aus TZ-Env-Variable, sonst aus der
|
|
// lokalen Zeit-Location des Servers.
|
|
serverTimezone := os.Getenv("TZ")
|
|
if serverTimezone == "" {
|
|
serverTimezone = time.Now().Location().String()
|
|
}
|
|
|
|
userRole := ""
|
|
if u := reqcontext.UserFromContext(r.Context()); u != nil {
|
|
userRole = u.Role
|
|
}
|
|
|
|
renderTemplate(w, t, map[string]any{
|
|
"Screen": screen,
|
|
"Tenant": tenant,
|
|
"Playlist": playlist,
|
|
"Items": items,
|
|
"Assets": assets,
|
|
"AddedAssets": addedAssets,
|
|
"BackLink": backLink,
|
|
"BackLabel": backLabel,
|
|
"IsAdmin": isAdmin,
|
|
"AccessibleScreens": accessibleScreens,
|
|
"ServerTimezone": serverTimezone,
|
|
"CSRFToken": csrfToken,
|
|
"DisplayState": displayState,
|
|
"Schedule": schedule,
|
|
"UserRole": userRole,
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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.Redirect(w, r, "/admin?tab=screens&msg=error_bad_form", http.StatusSeeOther)
|
|
return
|
|
}
|
|
slug := strings.TrimSpace(r.FormValue("slug"))
|
|
name := strings.TrimSpace(r.FormValue("name"))
|
|
orientation := r.FormValue("orientation")
|
|
if slug == "" || name == "" {
|
|
http.Redirect(w, r, "/admin?tab=screens&msg=error_empty", http.StatusSeeOther)
|
|
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 {
|
|
slog.Error("create screen: tenant not found", "event", "create_screen_tenant_not_found",
|
|
"tenant_slug", tenantSlug, "err", err)
|
|
http.Redirect(w, r, "/admin?tab=screens&msg=error_tenant", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
_, err = screens.Create(r.Context(), tenant.ID, slug, name, orientation)
|
|
if err != nil {
|
|
slog.Error("create screen failed", "event", "create_screen_failed",
|
|
"tenant_slug", tenantSlug, "slug", slug, "err", err)
|
|
http.Redirect(w, r, "/admin?tab=screens&msg=error_exists", http.StatusSeeOther)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/admin?tab=screens&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.Redirect(w, r, "/admin?tab=screens&msg=error_bad_form", http.StatusSeeOther)
|
|
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.Redirect(w, r, "/admin?tab=screens&msg=error_empty", http.StatusSeeOther)
|
|
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 {
|
|
slog.Error("provision screen: tenant not found", "event", "provision_screen_tenant_not_found",
|
|
"tenant_slug", tenantSlug, "err", err)
|
|
http.Redirect(w, r, "/admin?tab=screens&msg=error_tenant", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
screen, err := screens.Upsert(r.Context(), tenant.ID, slug, name, orientation)
|
|
if err != nil {
|
|
slog.Error("provision screen failed", "event", "provision_screen_failed",
|
|
"tenant_slug", tenantSlug, "slug", slug, "err", err)
|
|
http.Redirect(w, r, "/admin?tab=screens&msg=error_db", http.StatusSeeOther)
|
|
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 {
|
|
if errors.Is(err, store.ErrReorderMismatch) {
|
|
http.Error(w, "item list mismatch", http.StatusBadRequest)
|
|
} else {
|
|
slog.Error("reorder failed", "err", err)
|
|
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)
|
|
if r.Header.Get("X-Requested-With") == "fetch" {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
}
|