### 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>
324 lines
11 KiB
Go
324 lines
11 KiB
Go
package tenant
|
||
|
||
const tenantDashTmpl = `<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Mein Dashboard – morz infoboard</title>
|
||
<link rel="stylesheet" href="/static/bulma.min.css">
|
||
<style>
|
||
body { background: #f5f5f5; min-height: 100vh; }
|
||
.navbar { margin-bottom: 0; }
|
||
.tab-content { display: none; }
|
||
.tab-content.is-active { display: block; }
|
||
.progress-bar-wrap { display: none; margin-top: .5rem; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
|
||
<div class="navbar-brand">
|
||
<span class="navbar-item"><strong>📺 Infoboard</strong></span>
|
||
</div>
|
||
<div class="navbar-menu">
|
||
<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>
|
||
|
||
<section class="section">
|
||
<div class="container">
|
||
|
||
<h1 class="title is-4 mb-4">{{.Tenant.Name}}</h1>
|
||
|
||
{{if .Flash}}
|
||
<div class="notification is-success is-light mb-4">
|
||
<button class="delete" onclick="this.parentElement.remove()"></button>
|
||
{{.Flash}}
|
||
</div>
|
||
{{end}}
|
||
|
||
<div class="tabs is-boxed mb-0" id="dash-tabs">
|
||
<ul>
|
||
<li class="is-active" data-tab="monitors">
|
||
<a><span>Meine Monitore</span></a>
|
||
</li>
|
||
<li data-tab="media">
|
||
<a><span>Mediathek</span></a>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<!-- Tab A: Meine Monitore -->
|
||
<div id="tab-monitors" class="tab-content is-active">
|
||
<div class="box" style="border-top-left-radius:0">
|
||
{{if .Screens}}
|
||
<div class="columns is-multiline">
|
||
{{range .Screens}}
|
||
<div class="column is-4">
|
||
<div class="card">
|
||
<div class="card-content">
|
||
<p class="title is-5">
|
||
{{if eq .Orientation "portrait"}}📱{{else}}🖥{{end}}
|
||
{{.Name}}
|
||
</p>
|
||
<p class="subtitle is-6 has-text-grey">
|
||
{{if eq .Orientation "portrait"}}Hochformat{{else}}Querformat{{end}}
|
||
</p>
|
||
<div id="status-{{.Slug}}" class="mb-3">
|
||
<span class="tag is-warning">Unbekannt</span>
|
||
</div>
|
||
</div>
|
||
<footer class="card-footer">
|
||
<a class="card-footer-item"
|
||
href="/manage/{{.Slug}}?from=tenant">
|
||
Playlist bearbeiten
|
||
</a>
|
||
</footer>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
{{else}}
|
||
<p class="has-text-grey">Noch keine Monitore zugewiesen.</p>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tab B: Mediathek -->
|
||
<div id="tab-media" class="tab-content">
|
||
<div class="box" style="border-top-left-radius:0">
|
||
|
||
<h2 class="title is-5">Medium hochladen</h2>
|
||
|
||
<form id="upload-form" method="POST"
|
||
action="/tenant/{{.Tenant.Slug}}/upload"
|
||
enctype="multipart/form-data">
|
||
|
||
<div class="field">
|
||
<label class="label">Typ</label>
|
||
<div class="control">
|
||
<div class="select">
|
||
<select name="type" id="upload-type" onchange="toggleUploadFields()">
|
||
<option value="image">Bild</option>
|
||
<option value="video">Video</option>
|
||
<option value="pdf">PDF</option>
|
||
<option value="web">Website (URL)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label class="label">Titel (optional)</label>
|
||
<div class="control">
|
||
<input class="input" type="text" name="title" placeholder="Wird aus Dateinamen abgeleitet">
|
||
</div>
|
||
</div>
|
||
|
||
<div id="file-field" class="field">
|
||
<label class="label">Datei</label>
|
||
<div class="control">
|
||
<input class="input" type="file" name="file" id="upload-file"
|
||
accept="image/*,video/*,application/pdf">
|
||
</div>
|
||
<div class="progress-bar-wrap" id="upload-progress-wrap">
|
||
<progress class="progress is-info" id="upload-progress" value="0" max="100"></progress>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="url-field" class="field" style="display:none">
|
||
<label class="label">URL</label>
|
||
<div class="control">
|
||
<input class="input" type="url" name="url" placeholder="https://...">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<div class="control">
|
||
<button class="button is-primary" type="submit" id="upload-btn">Hochladen</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
|
||
<hr>
|
||
|
||
<h2 class="title is-5">Vorhandene Medien</h2>
|
||
|
||
{{if .Assets}}
|
||
<div style="overflow-x:auto">
|
||
<table class="table is-fullwidth is-hoverable is-striped">
|
||
<thead>
|
||
<tr>
|
||
<th>Typ</th>
|
||
<th>Titel</th>
|
||
<th>Größe</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{{range .Assets}}
|
||
<tr>
|
||
<td>{{typeIcon .Type}}</td>
|
||
<td>{{.Title}}</td>
|
||
<td class="has-text-grey is-size-7">
|
||
{{if .SizeBytes}}{{humanSize .SizeBytes}}{{else}}–{{end}}
|
||
</td>
|
||
<td>
|
||
<form method="POST"
|
||
action="/tenant/{{$.Tenant.Slug}}/media/{{.ID}}/delete"
|
||
onsubmit="return confirm('Wirklich löschen?')">
|
||
<button class="button is-small is-danger is-outlined" type="submit">Löschen</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
{{end}}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{{else}}
|
||
<p class="has-text-grey">Noch keine Medien hochgeladen.</p>
|
||
{{end}}
|
||
|
||
</div>
|
||
</div><!-- /tab-media -->
|
||
|
||
</div>
|
||
</section>
|
||
|
||
<script>
|
||
// ── Tab-Switching ────────────────────────────────────────────────────────────
|
||
(function() {
|
||
var tabs = document.querySelectorAll('#dash-tabs li[data-tab]');
|
||
tabs.forEach(function(tab) {
|
||
tab.addEventListener('click', function() {
|
||
tabs.forEach(function(t) { t.classList.remove('is-active'); });
|
||
tab.classList.add('is-active');
|
||
document.querySelectorAll('.tab-content').forEach(function(c) {
|
||
c.classList.remove('is-active');
|
||
});
|
||
document.getElementById('tab-' + tab.dataset.tab).classList.add('is-active');
|
||
// Sync URL ohne Reload
|
||
var url = new URL(window.location.href);
|
||
url.searchParams.set('tab', tab.dataset.tab);
|
||
history.replaceState(null, '', url.toString());
|
||
});
|
||
});
|
||
|
||
// Beim Laden ggf. Tab aus URL herstellen
|
||
var tabParam = new URLSearchParams(window.location.search).get('tab');
|
||
if (tabParam) {
|
||
var target = document.querySelector('#dash-tabs li[data-tab="' + tabParam + '"]');
|
||
if (target) target.click();
|
||
}
|
||
})();
|
||
|
||
// ── Upload-Formular Typ-Felder ────────────────────────────────────────────────
|
||
function toggleUploadFields() {
|
||
var t = document.getElementById('upload-type').value;
|
||
document.getElementById('file-field').style.display = (t === 'web') ? 'none' : '';
|
||
document.getElementById('url-field').style.display = (t === 'web') ? '' : 'none';
|
||
}
|
||
|
||
// ── Upload-Fortschrittsbalken ─────────────────────────────────────────────────
|
||
(function() {
|
||
var form = document.getElementById('upload-form');
|
||
if (!form) return;
|
||
form.addEventListener('submit', function(e) {
|
||
var t = document.getElementById('upload-type').value;
|
||
if (t === 'web') return; // kein XHR für URL-Typ
|
||
var file = document.getElementById('upload-file').files[0];
|
||
if (!file) return;
|
||
e.preventDefault();
|
||
|
||
var wrap = document.getElementById('upload-progress-wrap');
|
||
var bar = document.getElementById('upload-progress');
|
||
var btn = document.getElementById('upload-btn');
|
||
wrap.style.display = 'block';
|
||
btn.disabled = true;
|
||
btn.textContent = 'Lädt hoch…';
|
||
|
||
var fd = new FormData(form);
|
||
var xhr = new XMLHttpRequest();
|
||
xhr.open('POST', form.action);
|
||
xhr.upload.addEventListener('progress', function(ev) {
|
||
if (ev.lengthComputable) {
|
||
bar.value = Math.round(ev.loaded / ev.total * 100);
|
||
}
|
||
});
|
||
xhr.addEventListener('load', function() {
|
||
if (xhr.status >= 200 && xhr.status < 400) {
|
||
window.location.href = window.location.pathname + '?tab=media&flash=uploaded';
|
||
} else {
|
||
alert('Upload fehlgeschlagen: ' + xhr.responseText);
|
||
btn.disabled = false;
|
||
btn.textContent = 'Hochladen';
|
||
wrap.style.display = 'none';
|
||
}
|
||
});
|
||
xhr.addEventListener('error', function() {
|
||
alert('Netzwerkfehler beim Upload.');
|
||
btn.disabled = false;
|
||
btn.textContent = 'Hochladen';
|
||
wrap.style.display = 'none';
|
||
});
|
||
xhr.send(fd);
|
||
});
|
||
})();
|
||
|
||
// ── Status-Polling alle 30 s ──────────────────────────────────────────────────
|
||
(function() {
|
||
function pollStatus() {
|
||
fetch('/api/v1/screens/status')
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(list) {
|
||
if (!Array.isArray(list)) return;
|
||
list.forEach(function(s) {
|
||
var el = document.getElementById('status-' + s.screen_id);
|
||
if (!el) return;
|
||
var ok = s.status === 'ok' || s.status === 'online';
|
||
var cls = ok ? 'is-success' : 'is-danger';
|
||
var text = ok ? 'Online' : 'Offline';
|
||
el.innerHTML = '<span class="tag ' + cls + '">' + text + '</span>';
|
||
});
|
||
})
|
||
.catch(function() { /* ignore */ });
|
||
}
|
||
pollStatus();
|
||
setInterval(pollStatus, 30000);
|
||
})();
|
||
</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>`
|