morz-infoboard/server/backend/internal/httpapi/manage/templates.go
Jesko Anschütz dd3ec070f7 Security-Review + Phase 6: CSRF, Rate-Limiting, Tenant-Isolation, Screenshot, Ansible
### Security-Fixes (K1–K6, W1–W4, W7, N1, N5–N6, V1, V5–V7)
- K1: CSRF-Schutz via Double-Submit-Cookie (httpapi/csrf.go + csrf_helpers.go)
- K2: requireScreenAccess() in allen manage-Handlern (Tenant-Isolation)
- K3: Tenant-Check bei DELETE /api/v1/media/{id}
- K4: requirePlaylistAccess() + GetByItemID() für JSON-API Playlist-Routen
- K5: Admin-Passwort nur noch als [gesetzt] geloggt
- K6: POST /api/v1/screens/register mit Pre-Shared-Secret (MORZ_INFOBOARD_REGISTER_SECRET)
- W1: Race Condition bei order_index behoben (atomare Subquery in AddItem)
- W2: Graceful Shutdown mit 15s Timeout auf SIGTERM/SIGINT
- W3: http.MaxBytesReader (512 MB) in allen Upload-Handlern
- W4: err.Error() nicht mehr an den Client
- W7: Template-Execution via bytes.Buffer (kein partial write bei Fehler)
- N1: Rate-Limiting auf /login (5 Versuche/Minute pro IP, httpapi/ratelimit.go)
- N5: Directory-Listing auf /uploads/ deaktiviert (neuteredFileSystem)
- N6: Uploads nach Tenant getrennt (uploads/{tenantSlug}/)
- V1: Upload-Logik konsolidiert in internal/fileutil/fileutil.go
- V5: Cookie-Name als Konstante reqcontext.SessionCookieName
- V6: Strukturiertes Logging mit log/slog + JSON-Handler
- V7: DB-Pool wird im Graceful-Shutdown geschlossen

### Phase 6: Screenshot-Erzeugung
- player/agent/internal/screenshot/screenshot.go erstellt
- Integration in app.go mit MORZ_INFOBOARD_SCREENSHOT_EVERY Config

### UX: PDF.js Integration
- pdf.min.js + pdf.worker.min.js als lokale Assets eingebettet
- Automatisches Seitendurchblättern im Player

### Ansible: Neue Rollen
- signage_base, signage_server, signage_provision erstellt
- inventory.yml und site.yml erweitert

### Konzept-Docs
- GRUPPEN-KONZEPT.md, KAMPAGNEN-AKTIVIERUNG.md, MONITORING-KONZEPT.md
- PROVISION-KONZEPT.md, TEMPLATE-EDITOR.md, WATCHDOG-KONZEPT.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:06:35 +01:00

1025 lines
38 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package manage
const loginTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anmelden morz infoboard</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
body { background: #f5f5f5; min-height: 100vh; display: flex; flex-direction: column; }
.login-wrapper { flex: 1; display: flex; align-items: center; justify-content: center; padding: 2rem; }
</style>
</head>
<body>
<nav class="navbar is-dark">
<div class="navbar-brand">
<span class="navbar-item"><strong>morz infoboard</strong></span>
</div>
</nav>
<div class="login-wrapper">
<div class="columns is-centered" style="width:100%">
<div class="column is-narrow" style="min-width:340px;max-width:420px">
<div class="box">
<h1 class="title is-4 has-text-centered mb-5">Anmelden</h1>
{{if .Error}}
<div class="notification is-danger is-light">
<button class="delete" onclick="this.parentElement.remove()"></button>
{{.Error}}
</div>
{{end}}
<form method="POST" action="/login">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
{{if .Next}}
<input type="hidden" name="next" value="{{.Next}}">
{{end}}
<div class="field">
<label class="label" for="username">Benutzername</label>
<div class="control has-icons-left">
<input class="input" type="text" id="username" name="username"
autocomplete="username" autofocus required>
<span class="icon is-small is-left">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</span>
</div>
</div>
<div class="field">
<label class="label" for="password">Passwort</label>
<div class="control has-icons-left">
<input class="input" type="password" id="password" name="password"
autocomplete="current-password" required>
<span class="icon is-small is-left">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</span>
</div>
</div>
<div class="field mt-5">
<div class="control">
<button class="button is-dark is-fullwidth" type="submit">Anmelden</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</body>
</html>`
const provisionTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einrichten {{.Screen.Name}}</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
body { background: #f5f5f5; }
pre { background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 4px;
overflow-x: auto; font-size: 0.9em; line-height: 1.5; }
.step-number { background: #3273dc; color: #fff; border-radius: 50%;
width: 2rem; height: 2rem; display: inline-flex;
align-items: center; justify-content: center;
font-weight: bold; margin-right: 0.5rem; flex-shrink: 0; }
.step { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 2rem; }
.step-body { flex: 1; }
.copy-btn { cursor: pointer; font-size: 0.75em; }
</style>
</head>
<body>
<nav class="navbar is-dark">
<div class="navbar-brand">
<a class="navbar-item" href="/admin">← Admin</a>
<span class="navbar-item"><strong>Bildschirm einrichten: {{.Screen.Name}}</strong></span>
</div>
</nav>
<section class="section">
<div class="container" style="max-width:860px">
<div class="notification is-success is-light">
<strong>✓ Screen «{{.Screen.Name}}» ({{.Screen.Slug}}) wurde im Backend angelegt.</strong><br>
Führe die folgenden Schritte auf deinem Ansible-Host aus, um den Bildschirm zu provisionieren.
</div>
<!-- Schritt 1 -->
<div class="box">
<div class="step">
<span class="step-number">1</span>
<div class="step-body">
<p class="title is-6">Host zur Ansible-Inventardatei hinzufügen</p>
<p class="mb-3">Öffne <code>ansible/inventory.yml</code> und füge den Host unter <code>signage_players → hosts</code> ein:</p>
<pre id="inv"> {{.Screen.Slug}}:</pre>
<button class="button is-small is-light copy-btn mt-2" onclick="copy('inv')">📋 Kopieren</button>
</div>
</div>
</div>
<!-- Schritt 2 -->
<div class="box">
<div class="step">
<span class="step-number">2</span>
<div class="step-body">
<p class="title is-6">Host-Variablen anlegen</p>
<p class="mb-3">Erstelle die Datei <code>ansible/host_vars/{{.Screen.Slug}}/vars.yml</code> mit folgendem Inhalt:</p>
<pre id="hostvars">---
ansible_host: {{.IP}}
ansible_user: {{.SSHUser}}
screen_id: {{.Screen.Slug}}
screen_name: "{{.Screen.Name}}"
screen_orientation: {{.Orientation}}</pre>
<div class="buttons mt-2">
<button id="copy-btn-hostvars" class="button is-small is-light copy-btn" onclick="copy('hostvars', 'copy-btn-hostvars')">📋 Kopieren</button>
<button class="button is-small is-light" onclick="downloadFile(document.getElementById('hostvars').innerText, 'vars.yml')">⬇ Als Datei herunterladen</button>
</div>
<p class="help mt-2">Tipp: <code>mkdir -p ansible/host_vars/{{.Screen.Slug}}</code></p>
</div>
</div>
</div>
<!-- Schritt 3 -->
<div class="box">
<div class="step">
<span class="step-number">3</span>
<div class="step-body">
<p class="title is-6">SSH-Zugang sicherstellen</p>
<p>Stelle sicher, dass dein SSH-Key auf dem Zielgerät hinterlegt ist:</p>
<pre id="sshcopy">ssh-copy-id {{.SSHUser}}@{{.IP}}</pre>
<button class="button is-small is-light copy-btn mt-2" onclick="copy('sshcopy')">📋 Kopieren</button>
</div>
</div>
</div>
<!-- Schritt 4 -->
<div class="box">
<div class="step">
<span class="step-number">4</span>
<div class="step-body">
<p class="title is-6">Ansible-Playbook ausführen</p>
<p class="mb-3">Führe das Playbook vom Projektverzeichnis aus aus. Das installiert den Agent, konfiguriert Chromium und startet den Kiosk-Modus:</p>
<pre id="playbookcmd">cd /path/to/morz-infoboard
ansible-playbook -i ansible/inventory.yml ansible/site.yml --limit {{.Screen.Slug}}</pre>
<button class="button is-small is-light copy-btn mt-2" onclick="copy('playbookcmd')">📋 Kopieren</button>
<p class="help mt-2">
Falls du einen Vault-Pass verwendest:
<code>--vault-password-file ansible/.vault_pass</code>
</p>
</div>
</div>
</div>
<!-- Schritt 5 -->
<div class="box">
<div class="step">
<span class="step-number">5</span>
<div class="step-body">
<p class="title is-6">Fertig — Playlist befüllen</p>
<p>Nach erfolgreichem Ansible-Lauf meldet sich der Bildschirm automatisch im Backend an und lädt seine Playlist. Jetzt kannst du Inhalte zuweisen:</p>
<a class="button is-primary mt-3" href="/manage/{{.Screen.Slug}}">Playlist für «{{.Screen.Name}}» verwalten →</a>
</div>
</div>
</div>
</div>
</section>
<script>
function copy(id, btnId) {
var el = document.getElementById(id);
if (!el) return;
navigator.clipboard.writeText(el.innerText).then(function() {
var btn = btnId
? document.getElementById(btnId)
: el.nextElementSibling;
if (!btn) return;
var orig = btn.textContent;
btn.textContent = '✓ Kopiert!';
setTimeout(function() { btn.textContent = orig; }, 1500);
});
}
function downloadFile(content, filename) {
var a = document.createElement('a');
a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(content);
a.download = filename;
a.click();
}
</script>
</body>
</html>`
const adminTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MORZ Infoboard Admin</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<style>
body { background: #f5f5f5; }
.navbar { margin-bottom: 1.5rem; }
</style>
</head>
<body>
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<span class="navbar-item"><strong>📺 MORZ Infoboard</strong></span>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="adminNavbar">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="adminNavbar" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item" href="/status">Diagnose</a>
</div>
<div class="navbar-end">
<div class="navbar-item">
<form method="POST" action="/logout">
<button class="button is-light is-small" type="submit">Abmelden</button>
</form>
</div>
</div>
</div>
</nav>
<!-- Lösch-Bestätigungs-Modal -->
<div id="delete-modal" class="modal">
<div class="modal-background" onclick="closeDeleteModal()"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Bildschirm löschen?</p>
<button class="delete" aria-label="Schließen" onclick="closeDeleteModal()"></button>
</header>
<section class="modal-card-body">
<p>Soll <strong id="delete-modal-name"></strong> wirklich gelöscht werden?</p>
<p class="has-text-grey is-size-7 mt-2">Alle Playlist-Einträge werden ebenfalls gelöscht.</p>
</section>
<footer class="modal-card-foot">
<form id="delete-modal-form" method="POST">
<button class="button is-danger" type="submit">Wirklich löschen</button>
</form>
<button class="button" onclick="closeDeleteModal()">Abbrechen</button>
</footer>
</div>
</div>
<script>
(function() {
var burger = document.querySelector('.navbar-burger[data-target="adminNavbar"]');
if (burger) {
burger.addEventListener('click', function() {
var target = document.getElementById(burger.dataset.target);
burger.classList.toggle('is-active');
target.classList.toggle('is-active');
});
}
})();
function openDeleteModal(action, name) {
document.getElementById('delete-modal-form').action = action;
document.getElementById('delete-modal-name').textContent = name;
document.getElementById('delete-modal').classList.add('is-active');
}
function closeDeleteModal() {
document.getElementById('delete-modal').classList.remove('is-active');
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeDeleteModal();
});
</script>
<script>
(function() {
var msg = new URLSearchParams(window.location.search).get('msg');
if (!msg) return;
var texts = {
'uploaded': '✓ Medium erfolgreich hochgeladen.',
'deleted': '✓ Erfolgreich gelöscht.',
'saved': '✓ Änderungen gespeichert.',
'added': '✓ Erfolgreich hinzugefügt.'
};
var text = texts[msg] || '✓ Aktion erfolgreich.';
var n = document.createElement('div');
n.className = 'notification is-success';
n.style.cssText = 'position:fixed;top:1rem;right:1rem;z-index:9999;max-width:380px;box-shadow:0 4px 12px rgba(0,0,0,.15)';
n.innerHTML = '<button class="delete"></button>' + text;
n.querySelector('.delete').addEventListener('click', function() { n.remove(); });
document.body.appendChild(n);
setTimeout(function() {
n.style.transition = 'opacity .5s';
n.style.opacity = '0';
setTimeout(function() { n.remove(); }, 500);
}, 3000);
// Clean URL without reloading
var url = new URL(window.location.href);
url.searchParams.delete('msg');
history.replaceState(null, '', url.toString());
})();
</script>
<section class="section pt-0">
<div class="container">
<div class="box">
<h2 class="title is-5">Bildschirme</h2>
{{if .Screens}}
<div style="overflow-x: auto">
<table class="table is-fullwidth is-hoverable is-striped">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Format</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{{range .Screens}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td><code>{{.Slug}}</code></td>
<td>{{orientationLabel .Orientation}}</td>
<td id="status-{{.Slug}}"><span class="has-text-grey">⚪</span></td>
<td>
<a class="button is-small is-link" href="/manage/{{.Slug}}">Playlist verwalten</a>
&nbsp;
<button class="button is-small is-danger is-outlined"
type="button"
aria-label="Bildschirm {{.Name}} löschen"
onclick="openDeleteModal('/admin/screens/{{.ID}}/delete', '{{.Name}}')">Löschen</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="has-text-grey">Noch keine Bildschirme angelegt.</p>
{{end}}
</div>
<div class="box">
<h2 class="title is-5">Neuen Bildschirm einrichten</h2>
<p class="mb-4 has-text-grey">
Fülle die Angaben aus. Der Bildschirm wird im Backend angelegt und du erhältst
eine <strong>Schritt-für-Schritt-Anleitung</strong> mit allen nötigen Befehlen
für das Ansible-Deployment.
</p>
<form method="POST" action="/admin/screens/provision">
<div class="columns is-multiline">
<div class="column is-3">
<div class="field">
<label class="label">Slug / Hostname</label>
<div class="control">
<input class="input" type="text" name="slug" placeholder="z.B. info12" required
pattern="[a-z0-9-]+" title="Nur Kleinbuchstaben, Zahlen und Bindestriche">
</div>
<p class="help">Eindeutig, URL-sicher — wird als <code>screen_id</code> verwendet</p>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label">Anzeigename</label>
<div class="control">
<input class="input" type="text" name="name" placeholder="z.B. Kantine EG" required>
</div>
</div>
</div>
<div class="column is-2">
<div class="field">
<label class="label">IP-Adresse</label>
<div class="control">
<input class="input" type="text" name="ip" placeholder="10.0.0.X" required>
</div>
</div>
</div>
<div class="column is-2">
<div class="field">
<label class="label">SSH-User</label>
<div class="control">
<input class="input" type="text" name="ssh_user" placeholder="morz" value="morz">
</div>
</div>
</div>
<div class="column is-2">
<div class="field">
<label class="label">Format</label>
<div class="control">
<div class="select is-fullwidth">
<select name="orientation">
<option value="landscape">Querformat</option>
<option value="portrait">Hochformat</option>
</select>
</div>
</div>
</div>
</div>
</div>
<button class="button is-primary" type="submit">Anlegen &amp; Anleitung generieren →</button>
</form>
</div>
<div class="box">
<h2 class="title is-5">Bestehenden Screen manuell anlegen</h2>
<details>
<summary class="has-text-grey" style="cursor:pointer">Nur DB-Eintrag, kein Deployment (aufklappen)</summary>
<form method="POST" action="/admin/screens" class="mt-4">
<div class="columns is-vcentered">
<div class="column is-3">
<div class="field">
<label class="label">Slug</label>
<div class="control">
<input class="input" type="text" name="slug" placeholder="z.B. flur-eg" required
pattern="[a-z0-9-]+" title="Nur Kleinbuchstaben, Zahlen und Bindestriche">
</div>
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label">Name</label>
<div class="control">
<input class="input" type="text" name="name" placeholder="z.B. Flur Erdgeschoss" required>
</div>
</div>
</div>
<div class="column is-2">
<div class="field">
<label class="label">Format</label>
<div class="control">
<div class="select is-fullwidth">
<select name="orientation">
<option value="landscape">Querformat</option>
<option value="portrait">Hochformat</option>
</select>
</div>
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label">&nbsp;</label>
<button class="button is-outlined is-fullwidth" type="submit">Nur anlegen</button>
</div>
</div>
</div>
</form>
</details>
</div>
</div>
</section>
<script>
(function() {
fetch('/api/v1/screens/status')
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(data) {
if (!data || !data.screens) return;
var dots = { 'online': '🟢', 'degraded': '🟡', 'offline': '🔴' };
data.screens.forEach(function(s) {
var cell = document.getElementById('status-' + s.screen_id);
if (cell) {
cell.innerHTML = (dots[s.derived_state] || '⚪') + ' <small>' + s.derived_state + '</small>';
}
});
})
.catch(function() {});
})();
</script>
<script>
// K1: CSRF Double-Submit — füge Token aus Cookie in alle POST-Formulare ein.
(function() {
function getCookie(name) {
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
return m ? decodeURIComponent(m[1]) : '';
}
function injectCSRF() {
var token = getCookie('morz_csrf');
if (!token) return;
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
if (!f.querySelector('input[name="csrf_token"]')) {
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token;
f.appendChild(inp);
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectCSRF);
} else {
injectCSRF();
}
})();
</script>
</body>
</html>`
const manageTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Playlist {{.Screen.Name}}</title>
<link rel="stylesheet" href="/static/bulma.min.css">
<script src="/static/Sortable.min.js"></script>
<style>
body { background: #f5f5f5; }
.drag-handle { cursor: grab; color: #aaa; font-size: 1.2em; user-select: none; }
.drag-handle:hover { color: #333; }
.item-disabled td { opacity: 0.5; }
.edit-row td { background: #fffbf0; padding: 0.75rem 1rem; }
.tag-type { font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em; }
.sortable-ghost { background: #e8f4fd !important; }
.tab-panel { display: none; }
.tab-panel.is-active { display: block; }
</style>
</head>
<body>
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="{{.BackLink}}">{{.BackLabel}}</a>
<span class="navbar-item">
<strong>{{.Screen.Name}}</strong>
&nbsp;
<span class="tag is-info is-light">{{orientationLabel .Screen.Orientation}}</span>
</span>
<a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="manageNavbar">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="manageNavbar" class="navbar-menu">
<div class="navbar-start">
</div>
<div class="navbar-end">
<div class="navbar-item">
<form method="POST" action="/logout">
<button class="button is-light is-small" type="submit">Abmelden</button>
</form>
</div>
</div>
</div>
</nav>
<!-- Lösch-Bestätigungs-Modal -->
<div id="manage-delete-modal" class="modal">
<div class="modal-background" onclick="closeManageDeleteModal()"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title" id="manage-delete-modal-title">Eintrag entfernen?</p>
<button class="delete" aria-label="Schließen" onclick="closeManageDeleteModal()"></button>
</header>
<section class="modal-card-body">
<p id="manage-delete-modal-body">Soll der Eintrag wirklich entfernt werden?</p>
</section>
<footer class="modal-card-foot">
<form id="manage-delete-modal-form" method="POST">
<button class="button is-danger" type="submit">Wirklich löschen</button>
</form>
<button class="button" onclick="closeManageDeleteModal()">Abbrechen</button>
</footer>
</div>
</div>
<script>
(function() {
var msg = new URLSearchParams(window.location.search).get('msg');
if (!msg) return;
var texts = {
'uploaded': '✓ Medium erfolgreich hochgeladen.',
'deleted': '✓ Erfolgreich gelöscht.',
'saved': '✓ Änderungen gespeichert.',
'added': '✓ Erfolgreich hinzugefügt.'
};
var text = texts[msg] || '✓ Aktion erfolgreich.';
var n = document.createElement('div');
n.className = 'notification is-success';
n.style.cssText = 'position:fixed;top:1rem;right:1rem;z-index:9999;max-width:380px;box-shadow:0 4px 12px rgba(0,0,0,.15)';
n.innerHTML = '<button class="delete"></button>' + text;
n.querySelector('.delete').addEventListener('click', function() { n.remove(); });
document.body.appendChild(n);
setTimeout(function() {
n.style.transition = 'opacity .5s';
n.style.opacity = '0';
setTimeout(function() { n.remove(); }, 500);
}, 3000);
// Clean URL without reloading
var url = new URL(window.location.href);
url.searchParams.delete('msg');
history.replaceState(null, '', url.toString());
})();
function openManageDeleteModal(action, title, body) {
document.getElementById('manage-delete-modal-form').action = action;
document.getElementById('manage-delete-modal-title').textContent = title;
document.getElementById('manage-delete-modal-body').textContent = body;
document.getElementById('manage-delete-modal').classList.add('is-active');
}
function closeManageDeleteModal() {
document.getElementById('manage-delete-modal').classList.remove('is-active');
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeManageDeleteModal();
});
</script>
<section class="section pt-4">
<div class="container">
<!-- ── Playlist ── -->
<div class="box">
<h2 class="title is-5 mb-3">Aktuelle Playlist</h2>
{{if .Items}}
<div style="overflow-x: auto">
<table class="table is-fullwidth" id="playlist-table">
<thead>
<tr>
<th style="width:2rem"></th>
<th style="width:5rem">Typ</th>
<th>Titel / Quelle</th>
<th style="width:6rem">Dauer</th>
<th style="width:7rem">Status</th>
<th style="width:12rem">Aktionen</th>
</tr>
</thead>
<tbody id="sortable-items">
{{range .Items}}
<tr id="item-{{.ID}}" class="{{if not .Enabled}}item-disabled{{end}}">
<td class="drag-handle" role="button" aria-label="Reihenfolge ändern" tabindex="0" title="Ziehen zum Sortieren">⠿</td>
<td>
<span class="tag is-light tag-type">{{typeIcon .Type}}&nbsp;{{.Type}}</span>
</td>
<td>
<div>{{if .Title}}<strong>{{.Title}}</strong>{{else}}<em class="has-text-grey">{{shortSrc .Src}}</em>{{end}}</div>
{{if .Title}}<small class="has-text-grey">{{shortSrc .Src}}</small>{{end}}
</td>
<td>{{.DurationSeconds}}&thinsp;s</td>
<td>
{{if .Enabled}}
<span class="tag is-success is-light">Aktiv</span>
{{else}}
<span class="tag is-warning is-light">Deaktiviert</span>
{{end}}
</td>
<td>
<button class="button is-small is-info is-outlined" onclick="toggleEdit('{{.ID}}')">Bearbeiten</button>
<button class="button is-small is-danger is-outlined"
type="button"
aria-label="{{if .Title}}{{.Title}}{{else}}Eintrag{{end}} aus Playlist entfernen"
title="Entfernen"
onclick="openManageDeleteModal('/manage/{{$.Screen.Slug}}/items/{{.ID}}/delete', 'Eintrag entfernen?', 'Eintrag wirklich aus der Playlist entfernen?')">✕</button>
</td>
</tr>
<tr id="edit-{{.ID}}" class="edit-row" style="display:none">
<td colspan="6">
<form method="POST" action="/manage/{{$.Screen.Slug}}/items/{{.ID}}">
<div class="columns is-vcentered is-multiline">
<div class="column is-4">
<label class="label is-small">Titel</label>
<input class="input is-small" type="text" name="title" value="{{.Title}}"
placeholder="Anzeigename (optional)">
</div>
<div class="column is-narrow">
<label class="label is-small">Dauer (Sek.)</label>
<input class="input is-small" type="number" name="duration_seconds"
value="{{.DurationSeconds}}" min="1" max="3600" style="width:6rem">
</div>
<div class="column is-narrow">
<label class="label is-small">Gültig ab</label>
<input class="input is-small" type="datetime-local" name="valid_from"
value="{{formatDT .ValidFrom}}">
</div>
<div class="column is-narrow">
<label class="label is-small">Gültig bis</label>
<input class="input is-small" type="datetime-local" name="valid_until"
value="{{formatDT .ValidUntil}}">
</div>
<div class="column is-narrow">
<label class="label is-small">Aktiv</label>
<div class="control" style="padding-top:0.4rem">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true" {{if .Enabled}}checked{{end}}>
Aktiv
</label>
</div>
</div>
<div class="column is-narrow">
<label class="label is-small">&nbsp;</label>
<div class="buttons">
<button class="button is-small is-success" type="submit">Speichern</button>
<button class="button is-small" type="button" onclick="toggleEdit('{{.ID}}')">Abbrechen</button>
</div>
</div>
</div>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<p class="help has-text-grey mt-2">Einträge per Drag &amp; Drop in der Reihenfolge verschieben.</p>
{{else}}
<div class="notification is-light">
Die Playlist ist noch leer. Füge unten Medien aus der Bibliothek hinzu oder lade neue Dateien hoch.
</div>
{{end}}
</div>
<!-- ── Medienbibliothek ── -->
<div class="box">
<h2 class="title is-5 mb-3">Medienbibliothek</h2>
{{if .Assets}}
<div style="overflow-x: auto">
<table class="table is-fullwidth is-hoverable is-striped">
<thead>
<tr>
<th style="width:5rem">Typ</th>
<th>Titel</th>
<th>Quelle</th>
<th style="width:14rem">Aktionen</th>
</tr>
</thead>
<tbody>
{{range .Assets}}
<tr>
<td><span class="tag is-light tag-type">{{typeIcon .Type}}&nbsp;{{.Type}}</span></td>
<td>{{.Title}}</td>
<td>
<small class="has-text-grey">
{{if .StoragePath}}{{shortSrc .StoragePath}}{{else}}{{shortSrc .OriginalURL}}{{end}}
</small>
</td>
<td>
{{if index $.AddedAssets .ID}}
<span class="tag is-success is-light mr-2">✓ In Playlist</span>
{{else}}
<form method="POST" action="/manage/{{$.Screen.Slug}}/items" style="display:inline">
<input type="hidden" name="media_asset_id" value="{{.ID}}">
<button class="button is-small is-primary" type="submit">+ Hinzufügen</button>
</form>
&nbsp;
{{end}}
<button class="button is-small is-danger is-outlined"
type="button"
aria-label="{{.Title}} aus Bibliothek löschen"
title="Aus Bibliothek löschen"
onclick="openManageDeleteModal('/manage/{{$.Screen.Slug}}/media/{{.ID}}/delete', 'Medium löschen?', 'Medium wirklich aus der Bibliothek löschen? Playlist-Einträge bleiben bestehen, zeigen dann aber nichts an.')">🗑</button>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="has-text-grey">Noch keine Medien hochgeladen. Lade unten eine Datei hoch oder füge eine Webseite hinzu.</p>
{{end}}
</div>
<!-- ── Neues Medium hinzufügen ── -->
<div class="box">
<h2 class="title is-5 mb-3">Neues Medium hinzufügen</h2>
<div class="tabs" id="upload-tabs">
<ul>
<li id="tab-file" class="is-active"><a onclick="switchTab('file')">📁 Datei hochladen</a></li>
<li id="tab-web"><a onclick="switchTab('web')">🌐 Webseite / URL</a></li>
</ul>
</div>
<div id="panel-file" class="tab-panel is-active">
<form id="upload-form" method="POST" action="/manage/{{.Screen.Slug}}/upload" enctype="multipart/form-data">
<div class="columns is-vcentered">
<div class="column is-2">
<div class="field">
<label class="label">Typ</label>
<div class="select is-fullwidth">
<select name="type">
<option value="image">🖼 Bild</option>
<option value="video">🎬 Video</option>
<option value="pdf">📄 PDF</option>
</select>
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Titel <span class="has-text-grey is-size-7">(optional)</span></label>
<input class="input" type="text" name="title"
placeholder="Wird aus Dateinamen abgeleitet, wenn leer">
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Datei</label>
<div class="control">
<input class="input" type="file" name="file" id="upload-file-input" required
accept="image/*,video/*,application/pdf">
</div>
</div>
</div>
<div class="column is-narrow">
<div class="field">
<label class="label">&nbsp;</label>
<button class="button is-primary" type="button" id="upload-btn" onclick="startUpload()">Hochladen</button>
</div>
</div>
</div>
<div id="upload-progress-wrap" style="display:none" class="mt-2">
<progress id="upload-progress" class="progress is-primary" value="0" max="100">0%</progress>
</div>
<div id="upload-error" class="notification is-danger is-light mt-2" style="display:none"></div>
</form>
</div>
<div id="panel-web" class="tab-panel">
<form method="POST" action="/manage/{{.Screen.Slug}}/upload" enctype="multipart/form-data">
<input type="hidden" name="type" value="web">
<div class="columns is-vcentered">
<div class="column">
<div class="field">
<label class="label">URL</label>
<input class="input" type="url" name="url"
placeholder="https://example.com" required>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Titel <span class="has-text-grey is-size-7">(optional)</span></label>
<input class="input" type="text" name="title" placeholder="Anzeigename">
</div>
</div>
<div class="column is-narrow">
<div class="field">
<label class="label">&nbsp;</label>
<button class="button is-primary" type="submit">Hinzufügen</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</section>
<script>
// Navbar burger toggle
(function() {
var burger = document.querySelector('.navbar-burger[data-target="manageNavbar"]');
if (burger) {
burger.addEventListener('click', function() {
var target = document.getElementById(burger.dataset.target);
burger.classList.toggle('is-active');
target.classList.toggle('is-active');
});
}
})();
function toggleEdit(id) {
var row = document.getElementById('edit-' + id);
if (row) {
row.style.display = (row.style.display === 'none' || row.style.display === '') ? 'table-row' : 'none';
}
}
function switchTab(tab) {
var panels = ['file', 'web'];
panels.forEach(function(p) {
var panel = document.getElementById('panel-' + p);
var tabEl = document.getElementById('tab-' + p);
if (!panel || !tabEl) return;
if (p === tab) {
panel.classList.add('is-active');
tabEl.classList.add('is-active');
} else {
panel.classList.remove('is-active');
tabEl.classList.remove('is-active');
}
});
}
// Drag-and-drop reordering
var sortableEl = document.getElementById('sortable-items');
if (sortableEl) {
Sortable.create(sortableEl, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'sortable-ghost',
onEnd: function() {
var ids = [];
sortableEl.querySelectorAll('tr[id^="item-"]').forEach(function(tr) {
ids.push(tr.id.replace('item-', ''));
});
fetch('/manage/{{.Screen.Slug}}/reorder', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(ids)
});
}
});
}
// XHR-Upload mit Fortschrittsbalken
function startUpload() {
var form = document.getElementById('upload-form');
var fileInput = document.getElementById('upload-file-input');
var btn = document.getElementById('upload-btn');
var progressWrap = document.getElementById('upload-progress-wrap');
var progress = document.getElementById('upload-progress');
var errorBox = document.getElementById('upload-error');
errorBox.style.display = 'none';
if (!fileInput.files || fileInput.files.length === 0) {
errorBox.textContent = 'Bitte zuerst eine Datei auswählen.';
errorBox.style.display = '';
return;
}
var formData = new FormData(form);
var xhr = new XMLHttpRequest();
xhr.upload.onprogress = function(e) {
if (e.lengthComputable) {
var pct = Math.round((e.loaded / e.total) * 100);
progress.value = pct;
progress.textContent = pct + '%';
}
};
xhr.onloadstart = function() {
btn.style.display = 'none';
progressWrap.style.display = '';
progress.value = 0;
};
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 400) {
window.location.href = '/manage/{{.Screen.Slug}}?msg=uploaded';
} else {
progressWrap.style.display = 'none';
btn.style.display = '';
errorBox.textContent = 'Upload fehlgeschlagen (HTTP ' + xhr.status + '): ' + xhr.responseText;
errorBox.style.display = '';
}
};
xhr.onerror = function() {
progressWrap.style.display = 'none';
btn.style.display = '';
errorBox.textContent = 'Netzwerkfehler beim Upload. Bitte erneut versuchen.';
errorBox.style.display = '';
};
xhr.open('POST', form.action);
xhr.send(formData);
}
</script>
<script>
// K1: CSRF Double-Submit — füge Token aus Cookie in alle POST-Formulare ein.
(function() {
function getCookie(name) {
var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
return m ? decodeURIComponent(m[1]) : '';
}
function injectCSRF() {
var token = getCookie('morz_csrf');
if (!token) return;
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
if (!f.querySelector('input[name="csrf_token"]')) {
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token;
f.appendChild(inp);
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectCSRF);
} else {
injectCSRF();
}
})();
</script>
</body>
</html>`