Tenant-Feature Phase 3c + Phase 4: Register-Fix + Tenant-Dashboard UI
Phase 3c:
- register.go: hardcoded "morz" durch cfg.DefaultTenantSlug ersetzt
Phase 4:
- neues Package httpapi/tenant: HandleTenantDashboard, HandleTenantUpload, HandleTenantDeleteMedia
- tenantDashTmpl: Navbar, zwei Tabs (Monitore/Mediathek), Status-Polling, Upload-Fortschritt
- router.go: /tenant/{tenantSlug}/... Routen hinter RequireAuth+RequireTenantAccess
- manage/templates.go: Abmelden-Button in Admin-UI und Manage-UI Navbar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
27c4562175
commit
fb8d598e9e
5 changed files with 586 additions and 5 deletions
|
|
@ -5,16 +5,17 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||
)
|
||||
|
||||
// HandleRegisterScreen is called by the player agent on startup.
|
||||
// It upserts the screen in the default tenant (morz) so that all
|
||||
// It upserts the screen in the default tenant so that all
|
||||
// deployed screens appear automatically in the admin UI.
|
||||
//
|
||||
// POST /api/v1/screens/register
|
||||
// Body: {"slug":"info10","name":"Info10 Bildschirm","orientation":"landscape"}
|
||||
func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
|
||||
func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore, cfg config.Config) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Slug string `json:"slug"`
|
||||
|
|
@ -39,8 +40,8 @@ func HandleRegisterScreen(tenants *store.TenantStore, screens *store.ScreenStore
|
|||
body.Orientation = "landscape"
|
||||
}
|
||||
|
||||
// v1: single tenant — always register under "morz".
|
||||
tenant, err := tenants.Get(r.Context(), "morz")
|
||||
// Register under the configured default tenant.
|
||||
tenant, err := tenants.Get(r.Context(), cfg.DefaultTenantSlug)
|
||||
if err != nil {
|
||||
http.Error(w, "default tenant not found", http.StatusInternalServerError)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -250,6 +250,13 @@ const adminTmpl = `<!DOCTYPE html>
|
|||
<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>
|
||||
|
||||
|
|
@ -538,6 +545,13 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
<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>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi/manage"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi/tenant"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||
)
|
||||
|
|
@ -136,7 +137,7 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
|||
// ── JSON API — screens ────────────────────────────────────────────────
|
||||
// Self-registration: no auth (player calls this on startup).
|
||||
mux.HandleFunc("POST /api/v1/screens/register",
|
||||
manage.HandleRegisterScreen(d.TenantStore, d.ScreenStore))
|
||||
manage.HandleRegisterScreen(d.TenantStore, d.ScreenStore, d.Config))
|
||||
mux.Handle("GET /api/v1/tenants/{tenantSlug}/screens",
|
||||
authTenant(http.HandlerFunc(manage.HandleListScreens(d.TenantStore, d.ScreenStore))))
|
||||
mux.Handle("POST /api/v1/tenants/{tenantSlug}/screens",
|
||||
|
|
@ -166,4 +167,12 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
|||
authOnly(http.HandlerFunc(manage.HandleReorder(d.PlaylistStore, notifier))))
|
||||
mux.Handle("PATCH /api/v1/playlists/{playlistId}/duration",
|
||||
authOnly(http.HandlerFunc(manage.HandleUpdatePlaylistDuration(d.PlaylistStore))))
|
||||
|
||||
// ── Tenant self-service dashboard ─────────────────────────────────────
|
||||
mux.Handle("GET /tenant/{tenantSlug}/dashboard",
|
||||
authTenant(http.HandlerFunc(tenant.HandleTenantDashboard(d.TenantStore, d.ScreenStore, d.MediaStore))))
|
||||
mux.Handle("POST /tenant/{tenantSlug}/upload",
|
||||
authTenant(http.HandlerFunc(tenant.HandleTenantUpload(d.TenantStore, d.MediaStore, uploadDir))))
|
||||
mux.Handle("POST /tenant/{tenantSlug}/media/{mediaId}/delete",
|
||||
authTenant(http.HandlerFunc(tenant.HandleTenantDeleteMedia(d.TenantStore, d.MediaStore, uploadDir))))
|
||||
}
|
||||
|
|
|
|||
299
server/backend/internal/httpapi/tenant/templates.go
Normal file
299
server/backend/internal/httpapi/tenant/templates.go
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
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>
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
258
server/backend/internal/httpapi/tenant/tenant.go
Normal file
258
server/backend/internal/httpapi/tenant/tenant.go
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
// Package tenant implements the tenant self-service dashboard UI.
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||
)
|
||||
|
||||
var tmplFuncs = template.FuncMap{
|
||||
"typeIcon": func(t string) string {
|
||||
switch t {
|
||||
case "image":
|
||||
return "🖼"
|
||||
case "video":
|
||||
return "🎬"
|
||||
case "pdf":
|
||||
return "📄"
|
||||
case "web":
|
||||
return "🌐"
|
||||
default:
|
||||
return "📁"
|
||||
}
|
||||
},
|
||||
"humanSize": func(b int64) string {
|
||||
switch {
|
||||
case b >= 1<<20:
|
||||
return fmt.Sprintf("%.1f MB", float64(b)/float64(1<<20))
|
||||
case b >= 1<<10:
|
||||
return fmt.Sprintf("%.0f KB", float64(b)/float64(1<<10))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const maxUploadSize = 512 << 20 // 512 MB
|
||||
|
||||
// HandleTenantDashboard renders the tenant self-service dashboard.
|
||||
func HandleTenantDashboard(
|
||||
tenantStore *store.TenantStore,
|
||||
screenStore *store.ScreenStore,
|
||||
mediaStore *store.MediaStore,
|
||||
) http.HandlerFunc {
|
||||
t := template.Must(template.New("tenant-dash").Funcs(tmplFuncs).Parse(tenantDashTmpl))
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tenantSlug := r.PathValue("tenantSlug")
|
||||
|
||||
tenant, err := tenantStore.Get(r.Context(), tenantSlug)
|
||||
if err != nil {
|
||||
http.Error(w, "Tenant nicht gefunden: "+tenantSlug, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
screens, err := screenStore.List(r.Context(), tenant.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
assets, err := mediaStore.List(r.Context(), tenant.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Flash message: prefer ?flash= (from tenant upload redirect),
|
||||
// also accept legacy ?msg= used by manage handlers.
|
||||
flash := ""
|
||||
if f := r.URL.Query().Get("flash"); f != "" {
|
||||
switch f {
|
||||
case "uploaded":
|
||||
flash = "Medium erfolgreich hochgeladen."
|
||||
case "deleted":
|
||||
flash = "Medium erfolgreich gelöscht."
|
||||
default:
|
||||
flash = f
|
||||
}
|
||||
} else if m := r.URL.Query().Get("msg"); m != "" {
|
||||
switch m {
|
||||
case "uploaded":
|
||||
flash = "Medium erfolgreich hochgeladen."
|
||||
case "deleted":
|
||||
flash = "Medium erfolgreich gelöscht."
|
||||
default:
|
||||
flash = m
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
t.Execute(w, map[string]any{ //nolint:errcheck
|
||||
"Tenant": tenant,
|
||||
"Screens": screens,
|
||||
"Assets": assets,
|
||||
"Flash": flash,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTenantUpload handles multipart file uploads and web-URL registrations
|
||||
// from the tenant dashboard, then redirects back.
|
||||
func HandleTenantUpload(
|
||||
tenantStore *store.TenantStore,
|
||||
mediaStore *store.MediaStore,
|
||||
uploadDir string,
|
||||
) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tenantSlug := r.PathValue("tenantSlug")
|
||||
|
||||
tenant, err := tenantStore.Get(r.Context(), tenantSlug)
|
||||
if err != nil {
|
||||
http.Error(w, "Tenant nicht gefunden: "+tenantSlug, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
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"))
|
||||
|
||||
switch assetType {
|
||||
case "web":
|
||||
url := strings.TrimSpace(r.FormValue("url"))
|
||||
if url == "" {
|
||||
http.Error(w, "URL erforderlich", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if title == "" {
|
||||
title = url
|
||||
}
|
||||
_, err = mediaStore.Create(r.Context(), tenant.ID, title, "web", "", url, "", 0)
|
||||
|
||||
case "image", "video", "pdf":
|
||||
file, header, ferr := r.FormFile("file")
|
||||
if ferr != nil {
|
||||
http.Error(w, "Datei erforderlich", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if title == "" {
|
||||
title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
|
||||
}
|
||||
mimeType := header.Header.Get("Content-Type")
|
||||
// Derive asset type from MIME if more specific.
|
||||
if detected := mimeToAssetType(mimeType); detected != "" {
|
||||
assetType = detected
|
||||
}
|
||||
ext := filepath.Ext(header.Filename)
|
||||
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitize(title), ext)
|
||||
destPath := filepath.Join(uploadDir, filename)
|
||||
|
||||
dest, ferr := os.Create(destPath)
|
||||
if ferr != nil {
|
||||
http.Error(w, "Speicherfehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer dest.Close()
|
||||
|
||||
size, cerr := io.Copy(dest, file)
|
||||
if cerr != nil {
|
||||
os.Remove(destPath) //nolint:errcheck
|
||||
http.Error(w, "Schreibfehler", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
storagePath := "/uploads/" + filename
|
||||
_, err = mediaStore.Create(r.Context(), tenant.ID, title, assetType, storagePath, "", mimeType, size)
|
||||
|
||||
default:
|
||||
http.Error(w, "Unbekannter Typ", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "DB-Fehler: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/tenant/"+tenantSlug+"/dashboard?tab=media&flash=uploaded", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTenantDeleteMedia deletes a media asset owned by the tenant.
|
||||
func HandleTenantDeleteMedia(
|
||||
tenantStore *store.TenantStore,
|
||||
mediaStore *store.MediaStore,
|
||||
uploadDir string,
|
||||
) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tenantSlug := r.PathValue("tenantSlug")
|
||||
mediaID := r.PathValue("mediaId")
|
||||
|
||||
// Verify tenant exists.
|
||||
tenant, err := tenantStore.Get(r.Context(), tenantSlug)
|
||||
if err != nil {
|
||||
http.Error(w, "Tenant nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
asset, err := mediaStore.Get(r.Context(), mediaID)
|
||||
if err != nil {
|
||||
http.Error(w, "Medium nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
// Ownership check.
|
||||
if asset.TenantID != tenant.ID {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if asset.StoragePath != "" {
|
||||
os.Remove(filepath.Join(uploadDir, filepath.Base(asset.StoragePath))) //nolint:errcheck
|
||||
}
|
||||
mediaStore.Delete(r.Context(), mediaID) //nolint:errcheck
|
||||
|
||||
http.Redirect(w, r, "/tenant/"+tenantSlug+"/dashboard?tab=media&flash=deleted", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// mimeToAssetType derives the asset type from a MIME type string.
|
||||
func mimeToAssetType(mime string) string {
|
||||
mime = strings.ToLower(strings.TrimSpace(mime))
|
||||
switch {
|
||||
case strings.HasPrefix(mime, "image/"):
|
||||
return "image"
|
||||
case strings.HasPrefix(mime, "video/"):
|
||||
return "video"
|
||||
case mime == "application/pdf":
|
||||
return "pdf"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// sanitize converts a string to a safe filename component.
|
||||
func sanitize(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if len(out) > 40 {
|
||||
out = out[:40]
|
||||
}
|
||||
return out
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue